<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Grzegorz Otto</title>
    <description>The latest articles on DEV Community by Grzegorz Otto (@grzott).</description>
    <link>https://dev.to/grzott</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3884762%2Fcd579b66-2825-45d2-a239-b14f203e77ab.png</url>
      <title>DEV Community: Grzegorz Otto</title>
      <link>https://dev.to/grzott</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/grzott"/>
    <language>en</language>
    <item>
      <title>Zero-allocation TypeScript game loops: 60 fps on a mid-range Android phone</title>
      <dc:creator>Grzegorz Otto</dc:creator>
      <pubDate>Tue, 26 May 2026 14:30:56 +0000</pubDate>
      <link>https://dev.to/grzott/zero-allocation-typescript-game-loops-60-fps-on-a-mid-range-android-phone-4653</link>
      <guid>https://dev.to/grzott/zero-allocation-typescript-game-loops-60-fps-on-a-mid-range-android-phone-4653</guid>
      <description>&lt;p&gt;16.67 ms sounds generous until a garbage collector decides to think.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgo1l5vbda1wjtdutf1vo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgo1l5vbda1wjtdutf1vo.png" alt=" " width="800" height="420"&gt;&lt;/a&gt;&lt;br&gt;
Across three Android devices (a year-old mid-tier, an older flagship, and a current flagship), release builds, Hermes runtime, a 2D TypeScript game loop has roughly that long to advance every entity, run every system, and flush every draw command to Skia before the display catches up. The zero-allocation TypeScript discipline keeps that budget intact across a 36-package engine and a shipped showcase game. Five rules carry the load: plain &lt;code&gt;{x, y}&lt;/code&gt; objects instead of Vec2 classes, an &lt;code&gt;out&lt;/code&gt; parameter made mandatory on every hot-path operation, one generic &lt;code&gt;Pool&amp;lt;T&amp;gt;&lt;/code&gt; reused everywhere transient objects appear, module-level scratch state for random-number generation, and an atlas render pipeline that groups draw commands by texture so the GPU sees one draw call per batch instead of one per sprite.&lt;/p&gt;

&lt;p&gt;Measured outcome (three Android devices, release build, Hermes, TestingBot Maestro sweep 2026-05-26): worst cell across five gameplay scenarios landed at 9.52 ms p95 frame-time (Pixel 10, particle-storm) - 7.15 ms of headroom against the 16.67 ms / 60 fps budget. The four real-gameplay flows stayed between 0.93 ms and 4.18 ms. The frame budget is decided at module load - not at the closure-allocating callsite an autocomplete suggested.&lt;/p&gt;
&lt;h2&gt;
  
  
  The frame budget on a mid-range Android
&lt;/h2&gt;

&lt;p&gt;16.67 ms is the 60-fps wall. Hermes ships the Hades concurrent garbage collector (GC) by default - Hades keeps pause times short, roughly 70%+ shorter than the older JavaScriptCore (JSC) engine, but "short" is not zero, and the pauses scale with allocation rate. A single allocation inside a &lt;code&gt;for&lt;/code&gt; loop over 300 particles is not one allocation: it is 300 allocations per frame, which is 18,000 allocations per second at 60 fps. Each one is a fresh object the GC has to trace and collect.&lt;/p&gt;

&lt;p&gt;The game loop itself - &lt;code&gt;onFixedUpdate&lt;/code&gt; / &lt;code&gt;onUpdate&lt;/code&gt; / &lt;code&gt;onLateUpdate&lt;/code&gt; / &lt;code&gt;onPreRender&lt;/code&gt; / &lt;code&gt;onPostRender&lt;/code&gt; signals, a fixed 16.67 ms step accumulator, a &lt;code&gt;maxDt&lt;/code&gt; cap to prevent the spiral of death - is defined in &lt;code&gt;packages/core/src/game-loop.ts&lt;/code&gt; and &lt;code&gt;packages/core/src/clock.ts&lt;/code&gt;. That structure is not the subject here. What the loop runs inside every signal is the subject.&lt;/p&gt;

&lt;p&gt;Five gameplay scenarios captured 2026-05-26 via TestingBot Maestro on three real Android devices, release build, Hermes runtime. Each cell is the p95 frame-time across a 30-second steady-state window, measured by a recorder that brackets the live game loop (postfx + ECS + Skia render), not a synthetic micro-bench. The 16.67 ms / 60 fps budget is the reference:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Galaxy A55&lt;/th&gt;
&lt;th&gt;Pixel 6&lt;/th&gt;
&lt;th&gt;Pixel 10&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;boot (ambient Zone 1)&lt;/td&gt;
&lt;td&gt;0.93 ms&lt;/td&gt;
&lt;td&gt;1.51 ms&lt;/td&gt;
&lt;td&gt;1.76 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;boss-smok (mid-FSM + bullets)&lt;/td&gt;
&lt;td&gt;1.00 ms&lt;/td&gt;
&lt;td&gt;1.58 ms&lt;/td&gt;
&lt;td&gt;3.40 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;boss-lucyper (3-phase postfx)&lt;/td&gt;
&lt;td&gt;1.11 ms&lt;/td&gt;
&lt;td&gt;1.61 ms&lt;/td&gt;
&lt;td&gt;2.03 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;zone-transitions (despawn + respawn every 5s)&lt;/td&gt;
&lt;td&gt;2.25 ms&lt;/td&gt;
&lt;td&gt;4.18 ms&lt;/td&gt;
&lt;td&gt;3.73 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;particle-storm (synthetic worst-case)&lt;/td&gt;
&lt;td&gt;3.67 ms&lt;/td&gt;
&lt;td&gt;6.69 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;9.52 ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Worst observed cell across the sweep: 9.52 ms (Pixel 10, particle-storm). That leaves 7.15 ms of headroom against the 16.67 ms 60-fps budget - roughly 43% of the frame unused at p95 even under the synthetic worst-case load. The four real-gameplay flows (boot, boss fights, zone transitions) sat between 0.93 ms and 4.18 ms - 75-95% of the budget unspent.&lt;/p&gt;

&lt;p&gt;Postfx-stage p95 stayed under 0.030 ms in every cell across the sweep. The screen-effect pipeline is not the bottleneck.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Methodology and caveats.&lt;/strong&gt; Each cell is a single TestingBot capture (n=1) from a cloud device farm, 2026-05-26. The 30-second window starts after stable steady state. "p95 frame-time" is the 95th-percentile of per-frame durations - one frame in twenty exceeded the listed value during the window. With n=1 per cell, this is a preliminary baseline, not a device-vs-device ranking; the noise floor between cloud-farm sessions is wider than several of the inter-device gaps in the table. A nightly cron is now accumulating samples; a future post will share rolling medians once the dataset is meaningful.&lt;/p&gt;

&lt;p&gt;This sweep also replaces a different measurement methodology from April 2026. The earlier numbers (Samsung Galaxy A54 5G, entity-count stress with 10/30/50 enemies and 50/150/300 bullets, average fps) covered a narrower scenario set on one device. The current sweep traded that for p95 frame-time across in-game gameplay flows on three devices, which is closer to how the shipped game actually runs.&lt;/p&gt;
&lt;h2&gt;
  
  
  Plain objects beat classes - zero-allocation TypeScript by construction
&lt;/h2&gt;

&lt;p&gt;The cheapest object is the one that already exists.&lt;/p&gt;

&lt;p&gt;A Vec2 class allocates on every construction. A plain &lt;code&gt;{ x, y }&lt;/code&gt; object allocated once and mutated in place does not. The engine uses a plain-object type plus a static namespace for all math operations - no prototype chain, no GC pressure from construction:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// packages/math/src/vec2.ts&lt;/span&gt;
&lt;span class="cm"&gt;/** Vec2 plain object type - no class, no GC pressure. */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Vec2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="cm"&gt;/** Creates a Vec2 plain object. */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;vec2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Vec2&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The static namespace includes an optional &lt;code&gt;out&lt;/code&gt; parameter on every operation. Optional means callers who do not care about allocations can ignore it. The mandatory-out variant removes the option entirely:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// packages/math/src/vec2.ts&lt;/span&gt;
  &lt;span class="cm"&gt;/** Component-wise addition. Writes into out if provided. */&lt;/span&gt;
  &lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Vec2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Vec2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;Vec2&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Vec2&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Vec2.add(a, b)&lt;/code&gt; returns a fresh object. That is the natural autocomplete completion - it is what a senior dev would reach for on first pass, and it is wrong in any loop over hundreds of entities. The discipline is to supply the &lt;code&gt;out&lt;/code&gt; buffer and never let that form ship in a hot path. The lint catches some obvious cases; code review catches the rest.&lt;/p&gt;

&lt;p&gt;For the genuinely hot path - computing a normalised velocity vector inside an enemy update loop - the engine drops the optional form entirely:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// packages/math/src/vec2.ts&lt;/span&gt;
  &lt;span class="cm"&gt;/** Zero-alloc; writes into `out`. Mandatory `out` so callers avoid temporaries in hot paths. */&lt;/span&gt;
  &lt;span class="nf"&gt;normalizeOut&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;speed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Vec2&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Vec2&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;len&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sqrt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dx&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;dx&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;dy&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;dy&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;len&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;inv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;speed&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;len&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;dx&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;inv&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;dy&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;inv&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;normalizeOut&lt;/code&gt; has no return-a-fresh-object path. The caller owns the buffer. In a 300-bullet update loop, that is 300 existing &lt;code&gt;Vec2&lt;/code&gt; structs written into - not 300 new ones allocated. Autocomplete will not write this form for you; the signature forces the discipline on the caller.&lt;/p&gt;

&lt;h2&gt;
  
  
  One Pool to rule them - twelve lines, three patterns
&lt;/h2&gt;

&lt;p&gt;The pool pattern is not new. Robert Nystrom's &lt;a href="https://gameprogrammingpatterns.com/object-pool.html" rel="noopener noreferrer"&gt;Game Programming Patterns&lt;/a&gt; documented it in 2014. What is new here is that one twelve-line generic primitive runs the entire 36-package engine plus every transient in Pan Tvardowski - particles, sprite-batch commands, audio sources, explosion FX slots. One shape, applied uniformly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// packages/core/src/pool.ts&lt;/span&gt;
&lt;span class="cm"&gt;/**
 * Generic object pool for zero-alloc gameplay.
 *
 * - acquire() returns a recycled or newly created object
 * - release(obj) returns it to the pool
 * - prewarm(count) pre-allocates objects
 */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Pool&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="na"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="na"&gt;factory&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="na"&gt;resetFn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="na"&gt;item&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="cm"&gt;/**
   * @param factory Function to create a new instance.
   * @param reset Optional function to reset an object before reuse.
   */&lt;/span&gt;
  &lt;span class="nf"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;factory&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reset&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;item&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;factory&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;factory&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resetFn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;reset&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="cm"&gt;/** Get an object from the pool (or create a new one). */&lt;/span&gt;
  &lt;span class="nf"&gt;acquire&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;factory&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="cm"&gt;/** Return an object to the pool. Calls reset if configured. */&lt;/span&gt;
  &lt;span class="nf"&gt;release&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;item&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resetFn&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resetFn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="cm"&gt;/** Pre-allocate objects into the pool. */&lt;/span&gt;
  &lt;span class="nf"&gt;prewarm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;factory&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="cm"&gt;/** Number of available objects in the pool. */&lt;/span&gt;
  &lt;span class="kd"&gt;get&lt;/span&gt; &lt;span class="nf"&gt;available&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="cm"&gt;/** Clear all pooled objects. */&lt;/span&gt;
  &lt;span class="nf"&gt;clear&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three real consumers, three patterns, one primitive:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ParticlePool&lt;/strong&gt; (&lt;code&gt;packages/particles/src/particle-pool.ts&lt;/code&gt;) wraps &lt;code&gt;Pool&amp;lt;Particle&amp;gt;&lt;/code&gt; and calls &lt;code&gt;prewarm(256)&lt;/code&gt; at construction - 256 particle slots ready before the first frame. The &lt;code&gt;update()&lt;/code&gt; method uses a swap-and-pop release so the active list never shifts: when a particle's lifetime hits zero, it swaps with the last element and pops, returning the dead particle to the pool in O(1).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SpriteBatch&lt;/strong&gt; (&lt;code&gt;packages/render/src/sprite-batch.ts&lt;/code&gt;) keeps an internal &lt;code&gt;_pool: SpriteDrawCommand[]&lt;/code&gt; and an &lt;code&gt;_acquire()&lt;/code&gt; method that pops from the pool or calls the factory. Every &lt;code&gt;draw()&lt;/code&gt; call writes into a recycled command object. At &lt;code&gt;flush()&lt;/code&gt;, every command returns to the pool and &lt;code&gt;_commands.length = 0&lt;/code&gt; resets the list in place - no &lt;code&gt;splice&lt;/code&gt;, no &lt;code&gt;filter&lt;/code&gt;, no GC.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pan Tvardowski explosion FX slots&lt;/strong&gt; - the third pattern is module-level rather than class-level: a fixed-size array prewarmed at module load, not inside a constructor:&lt;/p&gt;

&lt;p&gt;Lifecycle of one slot through that pool:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;At module load, &lt;code&gt;_spiralPool&lt;/code&gt; is prewarmed with 8 slots, each set to &lt;code&gt;{ entityId: -1, active: false }&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;triggerSpiralCollapse(entityId)&lt;/code&gt; walks the pool, finds the first slot where &lt;code&gt;!active&lt;/code&gt;, sets &lt;code&gt;slot.active = true&lt;/code&gt; and &lt;code&gt;slot.entityId = entityId&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The play-scene tick advances the &lt;code&gt;SpiralCollapse&lt;/code&gt; component until &lt;code&gt;elapsed &amp;gt;= duration&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;When the duration expires, &lt;code&gt;world.destroy(entityId)&lt;/code&gt; fires and the slot resets to &lt;code&gt;slot.active = false&lt;/code&gt;, &lt;code&gt;slot.entityId = -1&lt;/code&gt; - ready for the next collapse.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The slots themselves, prewarmed at module load in Pan Tvardowski:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// apps/pan-tvardowski/src/fx/explosions.ts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SPIRAL_POOL_SIZE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;SpiralSlot&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;entityId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;active&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;_spiralPool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SpiralSlot&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;_i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;_i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;SPIRAL_POOL_SIZE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;_i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;_spiralPool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;entityId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;active&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key point: &lt;strong&gt;prewarm sizing is a scene-load decision, not a per-frame one.&lt;/strong&gt; The worst time to grow a pool is during gameplay. All three patterns - class-level with constructor prewarm, class-level with internal pop/factory, and module-level with literal array init - move that cost to module or scene load. Once the game loop is running, no pool grows.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scratch state and the module-level linear congruential generator
&lt;/h2&gt;

&lt;p&gt;Random numbers should not allocate either.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Math.random()&lt;/code&gt; is a closure call. A per-call array of candidate values is worse. The engine uses a module-level scalar &lt;code&gt;_lcgS&lt;/code&gt; with a linear congruential generator (LCG) function &lt;code&gt;_lcgNext()&lt;/code&gt; - re-seeded per call from the spawn position so identical spawns produce identical particle sequences:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// apps/pan-tvardowski/src/fx/explosions.ts&lt;/span&gt;
&lt;span class="c1"&gt;// Scratch LCG state - zero-alloc; re-seeded per call.&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;_lcgS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;_lcgNext&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;_lcgS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_lcgS&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;16807&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;2147483647&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_lcgS&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2147483646&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// [inside _spawnParticles:]&lt;/span&gt;
&lt;span class="c1"&gt;// Seed the LCG reproducibly from position so identical spawns are consistent.&lt;/span&gt;
&lt;span class="nx"&gt;_lcgS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;31&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;17&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No &lt;code&gt;new&lt;/code&gt;, no array, no closure capture per call. One &lt;code&gt;number&lt;/code&gt; on the module scope, rewritten in place. The deterministic seeding from spawn position is load-bearing for replay testing - a future post will cover deterministic game testing in depth.&lt;/p&gt;

&lt;h2&gt;
  
  
  The render side - atlas batching is part of the allocation story
&lt;/h2&gt;

&lt;p&gt;A draw call is the heaviest object in the system. Allocating fewer is as important as pooling particles.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;SpriteBatch.flush()&lt;/code&gt; (&lt;code&gt;packages/render/src/sprite-batch.ts&lt;/code&gt;) groups all queued draw commands by the triple &lt;code&gt;(image, effect, alpha)&lt;/code&gt; - the atlas key. Every command that shares the same key goes into one batch. The render call fires once per batch - &lt;strong&gt;one draw call per atlas texture batch (per-texture, not per-sprite)&lt;/strong&gt;. A change in effect or alpha breaks a batch by design; that is a structural property, not a bug.&lt;/p&gt;

&lt;p&gt;At the end of &lt;code&gt;flush()&lt;/code&gt;, every &lt;code&gt;SpriteDrawCommand&lt;/code&gt; returns to the internal pool, &lt;code&gt;_commands.length = 0&lt;/code&gt; resets the list, and the batch records are cleared in place. The command objects survive across frames - the frame boundary is a reset, not a reconstruction.&lt;/p&gt;

&lt;p&gt;Microbench figures from the Bun harness (harness, not device - see Limits):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Benchmark&lt;/th&gt;
&lt;th&gt;Config&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;th&gt;Throughput&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Entity-Component-System (ECS) step&lt;/td&gt;
&lt;td&gt;1,000 entities (Pos+Vel)&lt;/td&gt;
&lt;td&gt;23.7 µs/frame&lt;/td&gt;
&lt;td&gt;42.2K ops/sec&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Render flush&lt;/td&gt;
&lt;td&gt;1,000 sprites in 1 atlas&lt;/td&gt;
&lt;td&gt;26.6 µs/frame&lt;/td&gt;
&lt;td&gt;37.6K ops/sec&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Integration frame&lt;/td&gt;
&lt;td&gt;1,000 entities + 300 particles&lt;/td&gt;
&lt;td&gt;9.01 ms/frame&lt;/td&gt;
&lt;td&gt;111 ops/sec&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The integration frame - Entity-Component-System step plus render flush plus particle update, 1,000 entities and 300 particles - completes in 9.01 ms on the desktop harness. That is inside the 16.67 ms / 60-fps budget by roughly 40%. The device p95 numbers (the scenario table earlier in this post) are what matter for shipped behaviour; the harness figures show the math fits on a desktop CPU.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the measurements say - and do not
&lt;/h2&gt;

&lt;p&gt;The three devices in this sweep span roughly two years of Android hardware: a Galaxy A55 (Exynos 1480, Android 14) as a mid-tier reference, a Pixel 6 (Tensor G1, Android 13) as an older flagship, and a Pixel 10 (Tensor G5, Android 16, 120 Hz) as a current flagship. All ran release builds on Hermes. iOS measurements are pending; no JavaScriptCore comparison exists yet.&lt;/p&gt;

&lt;p&gt;Every cell in the sweep cleared the 16.67 ms / 60 fps budget at p95. The worst cell (Pixel 10, particle-storm, 9.52 ms) is the floor of the headroom claim - everything else sat further inside the frame. With n=1 per cell, do not read inter-device differences as device rankings; read them as a baseline distribution.&lt;/p&gt;

&lt;p&gt;The harness microbenches (23.7 µs Entity-Component-System step, 26.6 µs render flush, 9.01 ms integration frame) are from &lt;code&gt;bun bench&lt;/code&gt; on a desktop CPU, not a phone CPU. They validate that the math fits the budget in isolation. The device p95 numbers above are what happened on actual hardware. Both matter; they are not the same measurement.&lt;/p&gt;

&lt;p&gt;As of 2026-05-20, the engine carried approximately 2,481 tests green and Pan Tvardowski carried 1,168 - the p95 numbers above came from that same engine running the shipped game's gameplay code paths, not from a synthetic stress harness.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Three Android devices, single OS family.&lt;/strong&gt; Every measurement in this post is Android, release build, Hermes runtime. No iOS, no JavaScriptCore (JSC) comparison. The sweep names three devices spanning a year-old mid-tier to a current flagship; with n=1 per cell this is a baseline, not a fleet model.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;p95 is one metric.&lt;/strong&gt; The long tail (p99, p99.9, frame spikes) is not in this snapshot. A frame that exceeds the 16.67 ms budget but stays inside p95 is hidden by definition. A nightly cron is now accumulating samples so a future post can publish rolling medians plus the long tail.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The April A54 stress test is retired.&lt;/strong&gt; That run used entity-count synthetic loads (10/30/50 enemies, 50/150/300 bullets) and reported average fps - including a heavy-mode dip to 48 fps. The new sweep traded that for p95 frame-time across in-game scenarios on three devices. The two datasets are not directly comparable; the May sweep is closer to shipped gameplay, the April sweep pushed harder on raw entity counts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Harness microbenches are not device measurements.&lt;/strong&gt; 23.7 µs Entity-Component-System (ECS) step and 26.6 µs render flush are from &lt;code&gt;bun bench&lt;/code&gt; on a desktop dev machine. They show the algorithm fits the frame budget; the device p95 numbers in the scenario table are what actually happens on a phone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Discipline, not a tool.&lt;/strong&gt; No lint rule catches "you closed over a fresh &lt;code&gt;{ x, y }&lt;/code&gt; inside a worklet." Code review catches it; tests do not. The &lt;code&gt;normalizeOut&lt;/code&gt; signature communicates intent but cannot enforce that every caller supplies a pre-existing buffer. The senior dev's job is to recognise the pattern and reject the natural autocomplete completion - which returns a fresh object - in any hot path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hermes regime, 2026.&lt;/strong&gt; Hades concurrent GC keeps pauses short but not zero. A closure-allocating callsite inside a 300-iteration update loop still moves the needle. The claim is that the discipline is robust under Hermes' GC model - not that Hermes removes the problem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The engine is not open-source software (OSS) yet.&lt;/strong&gt; The October 2026 Release Candidate (RC) target is when the engine publishes to npm. The code blocks and provenance paths in this post are the current public record; readers cannot inspect the repository today.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Not the orchestration sequel.&lt;/strong&gt; The patterns in this post predate any AI-directed session - they are discipline, not orchestration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Close
&lt;/h2&gt;

&lt;p&gt;The rule a reader can apply this week: in any hot loop, name the buffer you write into. If the call signature does not accept an &lt;code&gt;out&lt;/code&gt;, fix the signature first.&lt;/p&gt;

&lt;p&gt;The same discipline applies outside games. A React Native app running a Reanimated worklet, a Skia canvas, or a gesture handler doing per-frame coordinate math wants the same shape: pre-allocate the state object, mutate it in place on update, never close over a fresh &lt;code&gt;{ x, y }&lt;/code&gt; inside the worklet. An animated dashboard rendering 200 cards at 60 fps has the same problem as a game rendering 200 bullets - the frame budget is the frame budget.&lt;/p&gt;

&lt;p&gt;If you want the context this post grew from: &lt;a href="https://grzegorzotto.dev/blog/36-package-rn-game-engine-in-3-weeks" rel="noopener noreferrer"&gt;the 3-week build story&lt;/a&gt; for the origin, &lt;a href="https://grzegorzotto.dev/blog/the-react-native-game-engine-gap" rel="noopener noreferrer"&gt;the engine-gap post&lt;/a&gt; for the why, and &lt;a href="https://grzegorzotto.dev/blog/architecture-before-the-ai-build" rel="noopener noreferrer"&gt;the architecture deep-dive&lt;/a&gt; for the structural layer the discipline runs inside. The next deep-dive covers the Skia Atlas pipeline in detail - how &lt;code&gt;useRSXformBuffer&lt;/code&gt; drives the per-texture batching at higher sprite counts.&lt;/p&gt;




&lt;p&gt;Originally posted on &lt;a href="https://grzegorzotto.dev/blog/zero-alloc-game-loops-in-typescript" rel="noopener noreferrer"&gt;grzegorzotto.dev&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>typescript</category>
      <category>performance</category>
      <category>gameengine</category>
    </item>
    <item>
      <title>The React Native game engine gap in 2026 - RNGE, Skia, Phaser-in-WebView, expo-gl</title>
      <dc:creator>Grzegorz Otto</dc:creator>
      <pubDate>Tue, 12 May 2026 15:31:30 +0000</pubDate>
      <link>https://dev.to/grzott/the-react-native-game-engine-gap-in-2026-rnge-skia-phaser-in-webview-expo-gl-55hp</link>
      <guid>https://dev.to/grzott/the-react-native-game-engine-gap-in-2026-rnge-skia-phaser-in-webview-expo-gl-55hp</guid>
      <description>&lt;p&gt;I needed a 2D game engine for an action game I'm building in React Native. I went looking for one. There is no React Native game engine in 2026.&lt;/p&gt;

&lt;p&gt;There are four partial options - each serious in its own domain, each missing something fundamental for the others. I spent two weeks mapping exactly where each one breaks. By the end of the second week, I'd started writing the missing layer myself. This post is the map I built before that decision.&lt;/p&gt;

&lt;p&gt;The four options I evaluated: &lt;code&gt;react-native-game-engine&lt;/code&gt; (RNGE), Phaser 4 inside a WebView, the graphics primitives &lt;code&gt;expo-gl&lt;/code&gt; and &lt;code&gt;react-native-wgpu&lt;/code&gt;, and &lt;code&gt;@shopify/react-native-skia&lt;/code&gt; paired with Reanimated 4. None of these is a React Native game engine in the sense of "install it, add a game loop, render sprites, play audio, manage scenes." Each one is something else - a renderer, a context, a web engine in a sandbox, a dormant project - and each one fails at a specific cliff. Here is where each cliff sits and what it means.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option 1 - &lt;code&gt;react-native-game-engine&lt;/code&gt; (dormant, JS-only)
&lt;/h2&gt;

&lt;p&gt;RNGE was where I started. It is the only library that has ever called itself a React Native game engine, and the API is real - an entity-component update + render loop, the pattern any 2D engine starts from. I cloned it, ran the examples, and the architecture sketch convinced me for about an hour.&lt;/p&gt;

&lt;p&gt;Then I read the publish history. The last version is 1.2.0, published in June 2020 - nearly six years ago. Around 2,235 downloads per week, sustained by projects that cannot migrate, not by anyone actively choosing it. The issue tracker has been silent for years.&lt;/p&gt;

&lt;p&gt;The deeper problem was architectural, not bibliographic. RNGE renders each game entity by mounting a React Native View. There is no GPU path, no sprite atlas, no draw-call batching. On a mid-range Android, roughly 50 active entities is where frames start to drop. For a turn-based puzzle game with a handful of pieces, RNGE still works - that regime is real and the library still serves it. For anything action-shaped - bullets, particles, animated enemies - the ceiling appears before the game is interesting to play.&lt;/p&gt;

&lt;p&gt;This is a reference implementation with a community, not a maintained engine. I closed the tab.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option 2 - Phaser 4 in a WebView
&lt;/h2&gt;

&lt;p&gt;Phaser is one of the great open-source projects of the decade. Phaser 4 shipped GA in April 2026 with around 40,000 GitHub stars and roughly 166,000 npm downloads per week - those are real numbers from a real engine, on the web. If you are building a 2D game for browsers, this is what you reach for.&lt;/p&gt;

&lt;p&gt;Inside a React Native app, the integration is exactly one shape: you mount Phaser inside a &lt;code&gt;&amp;lt;WebView&amp;gt;&lt;/code&gt;. That is the entire bridge. There is no shared state with your RN navigation, no shared Reanimated layer, no direct access to native APIs. You are running two runtimes - the host RN app and an embedded Chromium-class browser - and they do not share anything that matters at the engine level. The WebView pays its own startup cost, fights the host app's GC, and sits behind a JS-to-JS bridge for any cross-boundary state.&lt;/p&gt;

&lt;p&gt;The performance reality on Android is hard. Phaser 4 itself is fast - the April "Caladan" release describes it as a "completely rewritten WebGL based renderer." But Phaser 4 running inside an Android WebView runs roughly 5-10× slower than the same Phaser scene running in a desktop browser, because the WebView's GPU driver paths and GC behavior are not Chrome's. iOS Safari's JIT narrows the gap. The architectural reality does not change: this is the web's engine running in a sandbox inside your app.&lt;/p&gt;

&lt;p&gt;If you already have a Phaser game and want to embed it inside an RN shell as a playable surface, the WebView route works and ships today. Several games on the App Store and Play Store do exactly this. But the project I was running was "render 2D game content using React Native's own rendering pipeline so it shares state with the rest of my app." Phaser-in-WebView is not that project. It is RN-as-a-launcher.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option 3 - &lt;code&gt;expo-gl&lt;/code&gt; and &lt;code&gt;react-native-wgpu&lt;/code&gt; (primitives, not engines)
&lt;/h2&gt;

&lt;p&gt;By the third checkpoint I had a hypothesis: maybe the answer is to build straight on the GPU primitives. There are two healthy primitive-layer libraries in the RN ecosystem in 2026. &lt;code&gt;expo-gl&lt;/code&gt; exposes a WebGL 2.0 context; its latest version, 55.0.13, was published in May 2026 - actively maintained, broad device support, in Expo Go. &lt;code&gt;react-native-wgpu&lt;/code&gt; exposes WebGPU on RN 0.81+ with the New Architecture, backed by Google's Dawn runtime. WebGPU on mobile React Native is real and shipping.&lt;/p&gt;

&lt;p&gt;Neither of these is an engine. They are graphics contexts.&lt;/p&gt;

&lt;p&gt;What they do not ship: a sprite batcher, a scene system, an asset pipeline, audio sync, an input abstraction, an ECS. Every one of those layers is on you. Building a 2D game on top of &lt;code&gt;expo-gl&lt;/code&gt; or &lt;code&gt;react-native-wgpu&lt;/code&gt; is the same as building a 2D game on top of WebGL or WebGPU in any other host - perfectly legitimate, but the engine work is the project, not a starting point.&lt;/p&gt;

&lt;p&gt;TypeGPU and &lt;code&gt;react-three-fiber&lt;/code&gt;'s React Native template demonstrate the ecosystem is alive. They also confirm the engine layer above the primitives is empty. The infrastructure is there. Nobody has assembled it. That is not a criticism of the primitive libraries - they are doing exactly what they should - it is a statement about where the gap sits in the stack.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option 4 - Skia + Reanimated
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;@shopify/react-native-skia&lt;/code&gt; is where the evaluation actually changes shape. It is the only credible native GPU path inside React Native today, downloaded around 750,000 times per week. Paired with Reanimated 4 - the worklet layer that makes per-frame UI-thread computation viable - Skia is the closest thing the RN ecosystem has to an engine-grade renderer.&lt;/p&gt;

&lt;p&gt;A regime caveat first, because it is load-bearing. Reanimated 4 is &lt;strong&gt;New Architecture only&lt;/strong&gt; (Fabric, no Paper). The 4.0/4.1 line requires RN 0.78+; the current 4.3.x line (4.3.1 published 2026-05-07) requires RN 0.81+. Apps still on the legacy Paper renderer must stay on Reanimated 3.x, and Reanimated 3's worklet model is not the same animal as 4's. The Skia + Reanimated 4 architecture described here only exists if your app is on the New Architecture.&lt;/p&gt;

&lt;p&gt;The primitive that makes this viable is Skia's &lt;code&gt;&amp;lt;Atlas&amp;gt;&lt;/code&gt; API combined with Reanimated's &lt;code&gt;useRSXformBuffer&lt;/code&gt;. You define a sprite atlas, write a worklet that fills a typed buffer of RSX transforms - one per sprite - and Skia issues a single GPU draw call regardless of sprite count. The transforms are computed on the UI thread, not the JS thread. That is the architecture that makes fast 2D viable on React Native, full stop.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// @shopify/react-native-skia - Atlas + useRSXformBuffer&lt;/span&gt;
&lt;span class="c1"&gt;// docs: https://shopify.github.io/react-native-skia/docs/shapes/atlas/&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Atlas&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useRSXformBuffer&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@shopify/react-native-skia&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// One RSXform per sprite: [scos, ssin, tx, ty]&lt;/span&gt;
&lt;span class="c1"&gt;// Computed on the UI thread inside a worklet - no JS bridge per frame.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;transforms&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useRSXformBuffer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;spriteCount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;val&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;worklet&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;entity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;entities&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="nx"&gt;val&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;entity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cos&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;entity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;entity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sin&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;entity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;entity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;entity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Single GPU draw call regardless of spriteCount.&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Atlas&lt;/span&gt; &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;atlas&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;sprites&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;rects&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;transforms&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;transforms&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I ran the architecture across realistic game loads on my benchmark device - a Samsung Galaxy A54 5G, release build, Android. Light load (10 enemies + 50 bullets) held at 120 fps. Medium (30 enemies + 150 bullets) held at 117 fps. Heavy (50 enemies + 300 bullets) dropped to 48 fps. The cliff is real, but it sits where I'd want it to sit: at the edge of action-game intensity, not in the middle.&lt;/p&gt;

&lt;p&gt;Then I checked the cheap-Android floor and the entire calculus changed. On an OPPO A16 - the kind of budget device that represents a meaningful slice of the Indian and Eastern European markets I want this game to reach - the &lt;code&gt;&amp;lt;Atlas&amp;gt;&lt;/code&gt; API tops out at roughly 300 sprites before frames drop. That number comes from Skia GitHub issue #2521 - community-reported, not a controlled benchmark on my desk. The same issue reports an iPhone 12 mini holds up to around 15,000 sprites before slight drops appear. The spread is roughly 50×. If your game or animated UI needs 400 simultaneous sprites and your floor device is budget Android, the headline number is not the design number. The cheap-Android number is.&lt;/p&gt;

&lt;p&gt;That insight applies far beyond games. The Atlas + worklet path is exactly what powers animated dashboards, gesture-rich onboarding flows, and custom-rendered card UIs in non-game RN apps. The device floor applies there too. If you are shipping a high-touch animated UI to global Android, the Skia + Reanimated path is real and it is constrained by the same math.&lt;/p&gt;

&lt;p&gt;But - and this is where the journey ended for me - Skia is only the renderer. It does not ship sprite sheets, tilemaps, a scene stack, game-shaped audio, input gesture layers, or an ECS. You get a high-performance 2D canvas with good batching semantics on the New Architecture, and everything above that layer - the game engine - is yours to build. That is exactly what I started doing two weeks into this evaluation.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this means if you want a React Native game engine today
&lt;/h2&gt;

&lt;p&gt;After four checkpoints the map writes itself. The four options sit on a two-by-two grid, on two axes: whether they give you native GPU access (rows) and whether they ship a full engine layer - game loop, scenes, assets, audio, input - above the renderer (columns).&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;No engine layer (renderer/context only)&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;Full engine layer (loop, scenes, assets, audio, input)&lt;/strong&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Native GPU&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Skia + Reanimated 4 (renderer only); &lt;code&gt;expo-gl&lt;/code&gt; / &lt;code&gt;react-native-wgpu&lt;/code&gt; (context only)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;empty: the gap, does not exist in RN&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;No native GPU&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;react-native-game-engine&lt;/code&gt; (RN Views, dormant)&lt;/td&gt;
&lt;td&gt;Phaser 4 in WebView (full engine, separate runtime)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The top-right cell - native GPU rendering plus a full engine layer - is empty. That thing does not exist in React Native. That is the gap. That is what I started building.&lt;/p&gt;

&lt;p&gt;If you are mapping your own project against the grid, here is how I would pick from where I sit today:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;RNGE&lt;/strong&gt; if you have a handful-of-entities puzzle game, a prototype, or a project that needs to ship today and cannot take on a renderer migration. Accept the ceiling; it is real and it will not move.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Phaser-in-WebView&lt;/strong&gt; if you already have a finished Phaser game and need to embed it in an RN shell. This is a launcher, not an integration. The experience is bounded by WebView performance on your floor device.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;expo-gl&lt;/code&gt; / &lt;code&gt;react-native-wgpu&lt;/code&gt;&lt;/strong&gt; if you are building the engine on purpose. You want full control of the GPU stack, you understand the scope, and you have time. The primitives are excellent; the work above them is yours.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Skia + Reanimated&lt;/strong&gt; if your device floor sits above the OPPO A16 class, you accept building scenes, audio, input, and ECS yourself, and you want native GPU batching without leaving React Native. Do the device-floor math against your target market before you commit.&lt;/p&gt;

&lt;p&gt;None of these is "React Native game engine" off the shelf. That thing does not exist yet. I am now writing the layer that would close that quadrant. Whether anyone else needs it is the question I am answering by building it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits
&lt;/h2&gt;

&lt;p&gt;Four limits apply to everything above.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Single benchmark device.&lt;/strong&gt; Every frame number in this post - 120 fps, 117 fps, 48 fps - comes from a Samsung Galaxy A54 5G running a release build, Android only. iOS is not tested in this post's benchmarks. An iPad Pro M-series will laugh at every cliff named here. A 2020 budget Android will fall below the OPPO A16 floor before the numbers even apply.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Crowdsourced device-floor numbers.&lt;/strong&gt; The ~300 sprites on OPPO A16 and ~15,000 on iPhone 12 mini come from Skia GitHub issue #2521 - community reports, not a controlled benchmark I ran. Treat them as the device-floor lesson, not the device-floor table. I link the issue rather than presenting the numbers as measured data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This is a comparison, not a recommendation.&lt;/strong&gt; I do not say "use Skia + Reanimated" here. I say: if your device floor, sprite budget, and willingness to build the engine layer above the renderer all align, that is the only credible native GPU path in RN today. The fit analysis is yours to do.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Phaser-in-WebView is a real path for some games.&lt;/strong&gt; Content-heavy web ports with a finished Phaser game upstream and minimal RN integration ship today and work. The post's argument is about integration shape - "RN as a launcher" - not about whether the path is wrong. Do not confuse the framing with a dismissal.&lt;/p&gt;

&lt;p&gt;No comparison against Unity or Godot is attempted here. Native-first engines deploy via a separate runtime and a different store binary. They are outside the scope of "React Native rendering options."&lt;/p&gt;

&lt;h2&gt;
  
  
  Close
&lt;/h2&gt;

&lt;p&gt;The gap is real and it is shaped. Zero React Native game engines exist off the shelf in 2026. Four partial options exist, each viable inside its own regime, none viable across all of them. I confirmed that, then started writing the layer that would close the gap.&lt;/p&gt;

&lt;p&gt;Next Monday's post is the first chapter of the engine I started writing: the architecture decisions I made before any code, why a strict layering with downward-only dependencies is the load-bearing piece, and how it survives an AI-directed build at all.&lt;/p&gt;

&lt;p&gt;Originally published at &lt;a href="https://grzegorzotto.dev/blog/the-react-native-game-engine-gap" rel="noopener noreferrer"&gt;grzegorzotto.dev/blog&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>gamedev</category>
      <category>performance</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
