<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://jwmccarthy.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://jwmccarthy.github.io/" rel="alternate" type="text/html" /><updated>2026-10-21T03:49:14+00:00</updated><id>https://jwmccarthy.github.io/feed.xml</id><title type="html">Jack McCarthy</title><subtitle>Machine Learning Engineer at Instacart</subtitle><entry><title type="html">CARL: CUDA-Accelerated Rocket League</title><link href="https://jwmccarthy.github.io/posts/cuda-accelerated-rocket-league-intro/" rel="alternate" type="text/html" title="CARL: CUDA-Accelerated Rocket League" /><published>2026-10-20T00:00:00+00:00</published><updated>2026-10-20T00:00:00+00:00</updated><id>https://jwmccarthy.github.io/posts/cuda-accelerated-rocket-league-intro</id><content type="html" xml:base="https://jwmccarthy.github.io/posts/cuda-accelerated-rocket-league-intro/"><![CDATA[<p><img src="/images/carl.webp" alt="Carl from Jimmy Neutron" class="post-hero-image" /></p>

<p>This post introduces CARL, a GPU-resident Rocket League simulator built for reinforcement learning. Inspired by the likes of <a href="https://isaac-sim.github.io/IsaacLab/v3.0.0-EA/index.html">IsaacLab</a> <a href="#reference-isaac-lab">[1]</a> and <a href="https://chrislu.page/blog/meta-disco/">PureJaxRL</a> <a href="#reference-purejaxrl">[2]</a>, CARL’s parallel environment enables the rapid training of Rocket League agents. Many thousands of games can be run simultaneously on a single GPU, which exposes the policy to significantly more experience in less time than serial simulations. Credit goes to <a href="https://github.com/ZealanL/RocketSim">RocketSim</a> <a href="#reference-rocketsim">[3]</a> and the <a href="https://github.com/bulletphysics/bullet3">Bullet physics engine</a> <a href="#reference-bullet">[4]</a> for the ported logic.</p>

<h2 id="benchmarks">Benchmarks</h2>

<p>PPO was run on both CARL and RocketSim to evaluate training throughput. Benchmark runs were configured such that the same number of gradient updates were applied over the course of 1M decision steps. This was done by inversely scaling the rollout length by the number of envs while keeping all other parameters the same between runs with the two frameworks in question.</p>

<p>Given that this benchmark includes policy forward/backward, critic forward/backward, data transfer overhead, advantage estimates, and so on, we are able to paint a complete picture of the benefits of leveraging CARL on real-world training workloads.</p>

<table class="benchmark-table">
  <thead>
    <tr>
      <th>Framework</th>
      <th># Envs</th>
      <th>Time (s)</th>
      <th style="text-align: right">Throughput (transitions/s)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>RocketSim</td>
      <td>1</td>
      <td>475.90 ± 38.55</td>
      <td style="text-align: right">2,145 ± 159</td>
    </tr>
    <tr>
      <td>RocketSim</td>
      <td>4</td>
      <td>385.78 ± 2.66</td>
      <td style="text-align: right">2,633 ± 18</td>
    </tr>
    <tr>
      <td>RocketSim</td>
      <td>8</td>
      <td>326.03 ± 4.76</td>
      <td style="text-align: right">3,116 ± 45</td>
    </tr>
    <tr>
      <td>RocketSim</td>
      <td>16</td>
      <td>286.84 ± 2.16</td>
      <td style="text-align: right">3,542 ± 27</td>
    </tr>
    <tr>
      <td>CARL</td>
      <td>256</td>
      <td>15.11 ± 0.41</td>
      <td style="text-align: right">67,280 ± 1,830</td>
    </tr>
    <tr>
      <td>CARL</td>
      <td>1,024</td>
      <td>6.77 ± 0.20</td>
      <td style="text-align: right">150,106 ± 4,334</td>
    </tr>
    <tr>
      <td>CARL</td>
      <td>2,048</td>
      <td>4.53 ± 0.19</td>
      <td style="text-align: right">224,567 ± 8,963</td>
    </tr>
    <tr>
      <td><b>CARL</b></td>
      <td><b>4,096</b></td>
      <td><b>3.72 ± 0.24</b></td>
      <td style="text-align: right"><b>273,635 ± 16,379</b></td>
    </tr>
  </tbody>
</table>

<p class="table-caption">Benchmark code: <a href="https://github.com/jwmccarthy/carl-benchmarks">carl-benchmarks</a></p>

<details>
  <summary>Benchmark Hyperparameters</summary>

  <table class="benchmark-hyperparams" style="width: 50%">
  <thead>
    <tr><th>Parameter</th><th>Value</th></tr>
  </thead>
  <tbody>
    <tr><td>Algorithm</td><td>PPO</td></tr>
    <tr><td>Total timesteps</td><td>1M</td></tr>
    <tr><td>Epochs</td><td>10</td></tr>
    <tr><td>Minibatch size</td><td>32,768</td></tr>
    <tr><td>Total batch size</td><td>32,768</td></tr>
    <tr><td>Frameskip</td><td>4</td></tr>
    <tr><td>Max ticks per episode</td><td>36,000</td></tr>
  </tbody>
</table>
</details>

<p>Using CARL as our environment framework, we achieve a 77x decrease in the time per transition during training. Predictably, throughput scales with the number of parallel environments.</p>

<figure class="post-figure post-figure-carl-throughput">
  <img src="/images/carl-throughput.svg" alt="CARL throughput by number of environments" />
  <figcaption>CARL throughput scaling with the number of parallel environments.</figcaption>
</figure>

<p>Benchmarks were run on an Intel Core i5-13600K CPU, NVIDIA GeForce RTX 3060 GPU with 12 GiB VRAM, and 32 GiB RAM. GPUs with more SMs, Tensor Cores, etc. would open us up to greater throughput depending on the learning algorithm. VRAM, however, is not the simulation bottleneck, and at reasonable rollouts the observation buffer fits comfortably on the GPU at just a couple hundred MB. The simulation itself allocates just over 20 MB for 4,096 envs.</p>

<h2 id="interface">Interface</h2>
<p>CARL exposes the underlying simulation via the standard Gymnasium API. It is planned to add support for other ML libraries like JAX and TensorFlow, but the default wrapper at the moment is written in PyTorch. As with all other Gymnasium-wrapped environments, one need only initialize, reset, and step!</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">torch</span>
<span class="kn">from</span> <span class="nn">carl.gymnasium</span> <span class="kn">import</span> <span class="n">CARLTorchVectorEnv</span>

<span class="n">env</span> <span class="o">=</span> <span class="n">CARLTorchVectorEnv</span><span class="p">(</span><span class="n">n_sim</span><span class="o">=</span><span class="mi">8192</span><span class="p">)</span>

<span class="n">observation</span> <span class="o">=</span> <span class="n">env</span><span class="p">.</span><span class="n">reset</span><span class="p">()</span>

<span class="n">actions</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="n">zeros</span><span class="p">(</span>
    <span class="n">env</span><span class="p">.</span><span class="n">action_space</span><span class="p">.</span><span class="n">shape</span><span class="p">,</span>
    <span class="n">dtype</span><span class="o">=</span><span class="n">torch</span><span class="p">.</span><span class="n">float32</span><span class="p">,</span>
    <span class="n">device</span><span class="o">=</span><span class="n">env</span><span class="p">.</span><span class="n">device</span>
<span class="p">)</span>

<span class="n">observation</span><span class="p">,</span> <span class="n">reward</span><span class="p">,</span> <span class="n">terminated</span><span class="p">,</span> <span class="n">truncated</span><span class="p">,</span> <span class="n">info</span> <span class="o">=</span> <span class="n">env</span><span class="p">.</span><span class="n">step</span><span class="p">(</span><span class="n">actions</span><span class="p">)</span>
</code></pre></div></div>

<h3 id="action-space">Action Space</h3>
<p>Internally, CARL represents actions with shape $(n_{\mathrm{sim}}, n_{\mathrm{cars}}, 7)$, though users may treat each car as its own “environment”, where actions are submitted in the shape $(n_{\mathrm{sim}} * n_{\mathrm{cars}}, 7)$.</p>

<p>Actions by default are continuous, though the user can specify discrete actions via <code class="language-plaintext highlighter-rouge">discrete_actions=True</code>. In that case, the maximum continuous action is applied.</p>

<details>
  <summary>Action controls</summary>

  <table class="action-table">
    <thead>
      <tr>
        <th>Action</th>
        <th>Continuous</th>
        <th>Discrete</th>
        <th>Effect</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <td>Horizontal</td>
        <td>$[-1, 1]$</td>
        <td>$0, 1, 2$</td>
        <td>Steer on the ground and yaw in the air</td>
      </tr>
      <tr>
        <td>Vertical</td>
        <td>$[-1, 1]$</td>
        <td>$0, 1, 2$</td>
        <td>Pitch the car up or down</td>
      </tr>
      <tr>
        <td>Throttle</td>
        <td>$[-1, 1]$</td>
        <td>$0, 1, 2$</td>
        <td>Drive forward or reverse</td>
      </tr>
      <tr>
        <td>Powerslide</td>
        <td>$[0, 1]$</td>
        <td>$0, 1$</td>
        <td>Hold to drift and turn sharply</td>
      </tr>
      <tr>
        <td>Boost</td>
        <td>$[0, 1]$</td>
        <td>$0, 1$</td>
        <td>Hold to consume boost for acceleration</td>
      </tr>
      <tr>
        <td>Air roll</td>
        <td>$[-1, 1]$</td>
        <td>$0, 1, 2$</td>
        <td>Roll the car while airborne</td>
      </tr>
      <tr>
        <td>Jump</td>
        <td>$[0, 1]$</td>
        <td>$0, 1$</td>
        <td>Jump from the ground or dodge in the air</td>
      </tr>
    </tbody>
  </table>

</details>

<p>Actions are held for every tick over the subsequent <code class="language-plaintext highlighter-rouge">frameskip</code> ticks.</p>
<h3 id="observation-space">Observation Space</h3>
<p>The observation space has shape $(n_{\mathrm{sim}} * n_{\mathrm{cars}}, d_{\mathrm{obs}})$, where each <code class="language-plaintext highlighter-rouge">float32</code> row describes one car’s view of the game. Internally, CARL groups observations as $(n_{\mathrm{sim}}, n_{\mathrm{cars}}, d_{\mathrm{obs}})$. <code class="language-plaintext highlighter-rouge">CARLObservation</code> provides convenient named tensor views over these contiguous blocks.</p>

<details>
  <summary>Observation features</summary>

  <table class="action-table observation-table">
    <thead>
      <tr>
        <th>Block</th>
        <th>Contents</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <td>Ball</td>
        <td>Position, linear velocity, and angular velocity</td>
      </tr>
      <tr>
        <td>Cars</td>
        <td>Position, velocity, angular velocity, orientation, boost, and state flags</td>
      </tr>
      <tr>
        <td>Boost pads</td>
        <td>Active state and distance to the observing car</td>
      </tr>
      <tr>
        <td>Self to ball</td>
        <td>Relative position and velocity</td>
      </tr>
      <tr>
        <td>Self to other cars</td>
        <td>Relative position and velocity for every other car</td>
      </tr>
      <tr>
        <td>Goals</td>
        <td>Ball displacement to each goal, relative to the observing team</td>
      </tr>
      <tr>
        <td>Ego</td>
        <td>Flip and jump availability, plus remaining dodge-window time</td>
      </tr>
    </tbody>
  </table>

</details>

<details>
  <summary><code>CARLObservation</code> views</summary>

  <div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">observation</span><span class="p">.</span><span class="n">ball</span><span class="p">.</span><span class="n">position</span>           <span class="c1"># (n_envs, 3)
</span><span class="n">observation</span><span class="p">.</span><span class="n">cars</span><span class="p">.</span><span class="n">ego</span><span class="p">.</span><span class="n">boost</span>          <span class="c1"># (n_envs,)
</span><span class="n">observation</span><span class="p">.</span><span class="n">boost_pad_active</span>        <span class="c1"># (n_envs, 34)
</span><span class="n">observation</span><span class="p">.</span><span class="n">ego_ball_relative</span>       <span class="c1"># (n_envs, 6)
</span><span class="n">observation</span><span class="p">.</span><span class="n">ego_other_relative</span>      <span class="c1"># (n_envs, n_cars - 1, 6)
</span><span class="n">observation</span><span class="p">.</span><span class="n">own_goal_relative</span>       <span class="c1"># (n_envs, 3)
</span><span class="n">observation</span><span class="p">.</span><span class="n">ego_has_flip_or_jump</span>    <span class="c1"># (n_envs,)
</span></code></pre></div>  </div>

</details>

<p>By default CARL normalizes observations via the maximum values of arena extent, velocity, etc. though one may disable this via <code class="language-plaintext highlighter-rouge">normalize=False</code> in the environment constructor.</p>

<h3 id="custom-rewards">Custom Rewards</h3>
<p>Rewards are computed on-device through <code class="language-plaintext highlighter-rouge">RewardContext</code>. A reward function receives the previous state and present transition, observations, actions, game events, and episode metadata. It returns one reward per car with shape $(n_{\mathrm{sim}}, n_{\mathrm{cars}})$. Multiple reward functions are summed and multiplied by <code class="language-plaintext highlighter-rouge">reward_scale</code>. Registering custom rewards replaces the default score-only reward.</p>

<p>As an example, we can reward based on car velocity (higher-the-better):</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">carl</span>

<span class="k">def</span> <span class="nf">speed_reward</span><span class="p">(</span><span class="n">context</span><span class="p">):</span>
    <span class="k">return</span> <span class="n">context</span><span class="p">.</span><span class="n">current</span><span class="p">.</span><span class="n">car_velocity</span><span class="p">.</span><span class="n">norm</span><span class="p">(</span><span class="n">dim</span><span class="o">=-</span><span class="mi">1</span><span class="p">)</span> <span class="o">/</span> <span class="n">carl</span><span class="p">.</span><span class="n">CAR_MAX_SPEED</span>

<span class="n">env</span> <span class="o">=</span> <span class="n">CARLTorchVectorEnv</span><span class="p">(</span>
    <span class="n">n_sim</span><span class="o">=</span><span class="mi">1024</span><span class="p">,</span>
    <span class="n">n_blue</span><span class="o">=</span><span class="mi">1</span><span class="p">,</span>
    <span class="n">n_orange</span><span class="o">=</span><span class="mi">1</span><span class="p">,</span>
    <span class="n">reward_funcs</span><span class="o">=</span><span class="p">[</span><span class="n">speed_reward</span><span class="p">],</span>
<span class="p">)</span>
</code></pre></div></div>

<p>An arbitrary list of custom rewards may be applied, though it is usually best practice to unify rewards depending on their requisite context.</p>

<h2 id="implementation">Implementation</h2>

<p>As stated in the introduction, much of the logic in CARL is redundant with that of RocketSim/Bullet. However, a few alterations were made to make CARL a better fit for execution on the GPU. This section highlights those key differences.</p>

<h3 id="game-state">Game State</h3>
<p>The game state resides entirely on the GPU in a struct-of-arrays layout. During simulation, the kernels index into these arrays to perform data-dependent computations or modify state information.</p>

<figure class="post-figure">
  <img src="/images/aos-soa-memory-layout.png" alt="Comparison of array-of-structures and struct-of-arrays memory layouts" />
  <figcaption>Array-of-structures and struct-of-arrays memory layouts. <a href="#reference-zhao-et-al-2022">[5]</a></figcaption>
</figure>

<p>SoA is the optimal approach for data storage in GPU applications due to the enforcement of coalesced fetch instructions for contiguous elements. If we were instead to store arrays of structs, each associated with a single game’s state, the GPU would be forced to perform strided accesses, leading to more memory transactions. We’d also be pulling in full structs worth of data we may not need at the time.</p>

<p>triangles are accessed via CUDA’s cache-optimized read-only load intrinsics.</p>

<h3 id="arena-geometry">Arena Geometry</h3>
<p>The arena mesh is loaded into global memory upon environment initialization. In contrast to RocketSim’s <a href="https://en.wikipedia.org/wiki/Bounding_volume_hierarchy">bounding volume hierarchy (BVH)</a> broad phase <a href="#reference-rocketsim">[3]</a>, CARL divides the arena into a 3D broad phase grid of fixed size. The broad phase helps to reduce the number of triangle tests during collision resolution, and the fixed grid in particular enforces greater regularity of the triangle checks on the GPU.</p>

<p>CARL leverages the prefix sum construction described in NVIDIA’s <a href="https://developer.nvidia.com/gpugems/gpugems3/part-v-physics-simulation/chapter-32-broad-phase-collision-detection-cuda">Broad-Phase Collision Detection with CUDA</a> <a href="#reference-le-grand-2007">[6]</a>. The prefix sum converts each broad phase cell’s triangle occupancy into offsets/counts, allowing the car-arena and ball-arena collision kernels to determine where a cell’s triangle list begins and how many triangles it must test.</p>

<figure class="post-figure post-figure-compact post-figure-grid">
  <img src="/images/gpu-gems-cell-id-array.jpg" alt="Broad-phase cell ID array after construction" />
  <figcaption>Initial contents of the broad-phase cell ID array. <a href="#reference-le-grand-2007">[6]</a></figcaption>
</figure>

<p>It is quick to check both sphere and axis-aligned bounding box (AABB) occupancy, so we can quickly cull most of the triangles in the arena before checking specific tris against the car and ball.</p>

<h3 id="sat-narrow-phase">SAT Narrow Phase</h3>
<p>Unlike RocketSim, CARL does not use <a href="https://en.wikipedia.org/wiki/Gilbert%E2%80%93Johnson%E2%80%93Keerthi_distance_algorithm">GJK</a>/<a href="https://en.wikipedia.org/wiki/Expanding_polytope_algorithm">EPA</a> <a href="#reference-van-den-bergen-2004">[7]</a>. Instead, it implements the <a href="https://en.wikipedia.org/wiki/Hyperplane_separation_theorem#Separating_axis_theorem">Separating Axis Theorem (SAT)</a> <a href="#reference-van-den-bergen-2004">[7]</a> to identify car-arena and car-car collisions without iterative support mapping.</p>

<figure class="post-figure post-figure-half post-figure-sat">
  <img src="/images/separating-axis-theorem.webp" alt="Separating Axis Theorem overlap test" />
  <figcaption>Separating Axis Theorem OBB overlap test. <a href="#reference-tayyub-khan-2019">[8]</a></figcaption>
</figure>

<p>Sphere-OBB and sphere-triangle closest point tests generate car-ball and ball-arena contacts. Clipping via <a href="https://en.wikipedia.org/wiki/Sutherland%E2%80%93Hodgman_algorithm">Sutherland-Hodgman</a> <a href="#reference-sutherland-hodgman">[9]</a> constructs the car contact manifolds.</p>

<figure class="post-figure post-figure-half">
  <img src="/images/sutherland-hodgman-clipping.svg" alt="Steps of Sutherland-Hodgman polygon clipping" />
  <figcaption>Sutherland-Hodgman polygon clipping. <a href="#reference-sutherland-hodgman">[9]</a> Illustration by <a href="https://commons.wikimedia.org/wiki/File:Sutherland-Hodgman_clipping_sample.svg">Wojciech mula</a>, public domain.</figcaption>
</figure>

<p>These manifolds are persistent and updated each tick depending on the generation of new contact points. A maximum of 4 points is allowed per manifold in line with RocketSim.</p>

<h3 id="io">I/O</h3>
<p>Below the Gymnasium wrapper, <code class="language-plaintext highlighter-rouge">carl.Env</code> is a CUDA-only <a href="https://dmlc.github.io/dlpack/latest/">DLPack</a> interface. <a href="#reference-dlpack">[10]</a> Inputs must be contiguous tensors on the device. This way, no host round trip is required.</p>

<p>The native batch is organized by match rather than actor. All cars in a single simulation share one physics world, score, reward event, and termination/truncation flags. The PyTorch wrapper flattens the leading two dimensions so that each car appears as a separate environment.</p>

<p><code class="language-plaintext highlighter-rouge">step()</code> first packs the transition state and terminal observations, then automatically resets completed matches to shuffled kickoffs (or samples from user-specified reset states) and produces the next observation buffers. Returned DLPack tensors are non-owning views over CARL’s reusable GPU buffers, so callers that need to retain a result across a subsequent <code class="language-plaintext highlighter-rouge">step()</code> must clone or copy it. The Gymnasium PyTorch wrapper does this by default and expands match-level rewards and terminations back to each participating car.</p>

<h2 id="conclusion">Conclusion</h2>
<p>CARL represents a significant leap in Rocket League RL throughput, and it is my opinion that this is largely a good and valuable thing for the community. Thank you for reading if you made it this far! All love to Mr. Carl Wheezer.</p>

<p><b>Note:</b> If anyone who is familiar with the RLGym ecosystem finds any fault with the benchmarking methodology, please reach out! I’m confident CARL enables an appreciable training speed-up, but I’d like to ensure all comparisons are appropriate.</p>

<h2 id="references">References</h2>

<ol>
  <li id="reference-isaac-lab">NVIDIA. (n.d.). <a href="https://isaac-sim.github.io/IsaacLab/v3.0.0-EA/index.html"><em>Isaac Lab documentation</em></a>.</li>
  <li id="reference-purejaxrl">Lu, C. (n.d.). <a href="https://chrislu.page/blog/meta-disco/"><em>Meta-discoveries from PureJaxRL</em></a> [Blog post].</li>
  <li id="reference-rocketsim">ZealanL. (n.d.). <a href="https://github.com/ZealanL/RocketSim"><em>RocketSim</em></a> [Computer software]. GitHub.</li>
  <li id="reference-bullet">Bullet Physics. (n.d.). <a href="https://github.com/bulletphysics/bullet3"><em>Bullet Physics SDK</em></a> [Computer software]. GitHub.</li>
  <li id="reference-zhao-et-al-2022">Zhao, W.-L., Wang, W., and Wang, Q. (2022). <a href="https://doi.org/10.1007/s11227-021-04153-0"><em>Optimization of cosmological N-body simulation with FMM-PM on SIMT accelerators</em></a>. <em>The Journal of Supercomputing</em>, 78(5), 7186-7205.</li>
  <li id="reference-le-grand-2007">Le Grand, S. (2007). <a href="https://developer.nvidia.com/gpugems/gpugems3/part-v-physics-simulation/chapter-32-broad-phase-collision-detection-cuda"><em>Broad-phase collision detection with CUDA</em></a>. In H. Nguyen (Ed.), <em>GPU Gems 3</em> (Chapter 32). Addison-Wesley.</li>
  <li id="reference-van-den-bergen-2004">van den Bergen, G. (2004). <em>Collision detection in interactive 3D environments</em>. Morgan Kaufmann.</li>
  <li id="reference-tayyub-khan-2019">Tayyub, M., and Khan, G. N. (2019). <a href="https://doi.org/10.33965/ijcsis_2019140202"><em>Heterogeneous design and efficient CPU-GPU implementation of collision detection</em></a>. <em>IADIS International Journal on Computer Science and Information Systems</em>, 14(2), 25-40.</li>
  <li id="reference-sutherland-hodgman">Sutherland, I. E., and Hodgman, G. W. (1974). <a href="https://dl.acm.org/doi/10.1145/360767.360802"><em>Reentrant polygon clipping</em></a>. <em>Communications of the ACM</em>, 17(1), 32-42.</li>
  <li id="reference-dlpack">DLPack contributors. (n.d.). <a href="https://dmlc.github.io/dlpack/latest/"><em>DLPack documentation</em></a>.</li>
</ol>]]></content><author><name></name></author><summary type="html"><![CDATA[]]></summary></entry></feed>