<?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.us-east-2.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>The Turborepo + Bun + Biome stack behind a 40-package monorepo</title>
      <dc:creator>Grzegorz Otto</dc:creator>
      <pubDate>Wed, 08 Jul 2026 03:21:44 +0000</pubDate>
      <link>https://dev.to/grzott/the-turborepo-bun-biome-stack-behind-a-40-package-monorepo-3n3f</link>
      <guid>https://dev.to/grzott/the-turborepo-bun-biome-stack-behind-a-40-package-monorepo-3n3f</guid>
      <description>&lt;p&gt;Forty packages, one maintainer, and no ESLint config anywhere in the repo. That is not a boast - it is the direct result of a decision made early: every tool in the toolchain has to earn its place by governing all forty packages from one config file, not forty.&lt;/p&gt;

&lt;p&gt;The repo is &lt;code&gt;flare-engine&lt;/code&gt;, a modular 2D engine for React Native + Web (animation, gamification, interactive UI, with games as showcases - not a game engine, not a Unity or Godot competitor). The stack behind it is &lt;strong&gt;Turborepo Bun Biome&lt;/strong&gt;, and this post is the actual setup: the real &lt;code&gt;turbo.json&lt;/code&gt;, the real &lt;code&gt;biome.json&lt;/code&gt;, the real CI guardrail, straight from the repo (trimmed only where a config is long, and I say so where I trim), not a starter template's idealized version.&lt;/p&gt;

&lt;p&gt;Four binaries, four root configs - &lt;code&gt;turbo.json&lt;/code&gt;, &lt;code&gt;biome.json&lt;/code&gt;, &lt;code&gt;tsconfig.base.json&lt;/code&gt;, and the Changesets config - each governing all forty packages at once (Bun's own "config" is just the &lt;code&gt;workspaces&lt;/code&gt; array in the root &lt;code&gt;package.json&lt;/code&gt;). Plus a CI step that fails the build the moment a package imports something it shouldn't. That is the whole story, and I want to show you the files, not describe them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four binaries, not twelve config files
&lt;/h2&gt;

&lt;p&gt;The thesis is narrow: a solo maintainer can keep forty packages honest only if there is exactly one config of each kind, and every package extends it rather than declaring its own variant. Twelve packages each with a slightly different ESLint config is not a monorepo, it is twelve monorepos wearing a workspace file as a costume.&lt;/p&gt;

&lt;p&gt;Here is the root &lt;code&gt;package.json&lt;/code&gt; that runs all of it - Bun workspaces (not pnpm; that distinction matters and I will say it again below), the script table every package leans on, and the pinned package manager:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;C:\_PROG\flare-engine-workspace\flare-engine\package.json&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"flare-engine"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"0.0.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"private"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"workspaces"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"packages/*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"benchmarks"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"apps/*"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"scripts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"build"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"turbo build"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"test"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"turbo test"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"lint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"turbo lint"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"typecheck"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"turbo typecheck"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"check:deps"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bun scripts/check-deps-tiers.mjs"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"check:changeset"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bun scripts/check-changeset.mjs"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"lint:fix"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"biome check --write ."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"format"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"biome format --write ."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"release"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bun run propagate-license &amp;amp;&amp;amp; turbo build --filter='./packages/*' &amp;amp;&amp;amp; changeset publish"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"packageManager"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bun@1.2.0"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;workspaces&lt;/code&gt; is a plain array of globs - &lt;code&gt;packages/*&lt;/code&gt;, the private &lt;code&gt;benchmarks&lt;/code&gt; package, and &lt;code&gt;apps/*&lt;/code&gt;. No &lt;code&gt;pnpm-workspace.yaml&lt;/code&gt;, because there is no pnpm here. &lt;code&gt;scripts.build/test/lint/typecheck&lt;/code&gt; are one-liners that hand off to &lt;code&gt;turbo &amp;lt;task&amp;gt;&lt;/code&gt; - the root &lt;code&gt;package.json&lt;/code&gt; doesn't know how to build anything itself, it just fans the call out. &lt;code&gt;check:deps&lt;/code&gt; and &lt;code&gt;check:changeset&lt;/code&gt; are the two custom guardrails covered later. &lt;code&gt;lint:fix&lt;/code&gt; is &lt;code&gt;biome check --write .&lt;/code&gt; over the entire repo - one binary, every package, one pass.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bun as the whole toolbelt
&lt;/h2&gt;

&lt;p&gt;Bun is the second of the three legs of Turborepo Bun Biome, and it's doing more work than "package manager" implies - it is the runtime, the test runner, and the workspace mechanism in one binary. &lt;code&gt;bun install&lt;/code&gt; resolves the workspace graph and writes a text lockfile. &lt;code&gt;bun test&lt;/code&gt; runs every package's unit tests without a separate Jest or Vitest config. &lt;code&gt;bun scripts/check-deps-tiers.mjs&lt;/code&gt; runs a plain ESM script with zero build step.&lt;/p&gt;

&lt;p&gt;I want to be explicit about something, because it is easy to get wrong reading about this stack in 2026: &lt;strong&gt;this is Bun workspaces, not pnpm.&lt;/strong&gt; pnpm 10 is what runs this portfolio's own repo - a completely different project. Mixing the two up is a category error, and if you're evaluating "Bun workspaces vs pnpm" for your own monorepo, the honest answer is that flare-engine picked Bun specifically because collapsing the runtime, the package manager, and the test runner into one binary was worth more than pnpm's more mature workspace tooling.&lt;/p&gt;

&lt;p&gt;The versions in this post are a snapshot, resolved from &lt;code&gt;bun.lock&lt;/code&gt; on 2026-07-04: &lt;strong&gt;Bun 1.2.0&lt;/strong&gt;, &lt;strong&gt;Turborepo 2.9.6&lt;/strong&gt;, &lt;strong&gt;Biome 1.9.4&lt;/strong&gt;, &lt;strong&gt;TypeScript 5.9.3&lt;/strong&gt;. Bun 1.3, Turborepo 3.0, and Biome 2.2 already exist upstream as of this writing - if you're reading this later, check your own lockfile before copying a version number out of a blog post.&lt;/p&gt;

&lt;h2&gt;
  
  
  One turbo.json for 40 packages
&lt;/h2&gt;

&lt;p&gt;Turborepo Bun Biome is the whole toolchain, and &lt;code&gt;turbo.json&lt;/code&gt; is the file that makes the "one config governs forty packages" claim literal. Here it is in full - four tasks and a cache-key line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;C:\_PROG\flare-engine-workspace\flare-engine\turbo.json&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"$schema"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://turbo.build/schema.json"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"globalDependencies"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"tsconfig.base.json"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tsconfig.json"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"biome.json"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bunfig.toml"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tasks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"build"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"dependsOn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"^build"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"outputs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"dist/**"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;".next/**"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"!.next/cache/**"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"out/**"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"test"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"dependsOn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"build"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"lint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"typecheck"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"dependsOn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"^build"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;build.dependsOn: ["^build"]&lt;/code&gt; is the caret-prefixed dependency graph: before a package builds, every package it depends on has to build first (the &lt;code&gt;^&lt;/code&gt; means "this task in my dependencies," not "this task in me"). &lt;code&gt;outputs&lt;/code&gt; tells Turborepo which directories to hash and cache - &lt;code&gt;dist/**&lt;/code&gt; for the tsup output, &lt;code&gt;out/**&lt;/code&gt; for the one Next.js app in &lt;code&gt;apps/*&lt;/code&gt;. &lt;code&gt;globalDependencies&lt;/code&gt; does the inverse: it puts the root configs (&lt;code&gt;biome.json&lt;/code&gt;, &lt;code&gt;tsconfig.base.json&lt;/code&gt;, &lt;code&gt;bunfig.toml&lt;/code&gt;) into every task's cache key, so editing any one of them busts the cache for all forty packages at once, not just the package you touched. &lt;code&gt;test.dependsOn: ["build"]&lt;/code&gt; means tests only run against built output, never against stale &lt;code&gt;dist&lt;/code&gt;. &lt;code&gt;typecheck.dependsOn: ["^build"]&lt;/code&gt; needs its dependencies' type declarations to exist first, but not its own build (typecheck doesn't need itself pre-built - &lt;code&gt;tsc --noEmit&lt;/code&gt; reads &lt;code&gt;.ts&lt;/code&gt; source directly). &lt;code&gt;lint: {}&lt;/code&gt; has no dependency at all - it is fully parallel, because Biome only ever looks at one package's own &lt;code&gt;src/&lt;/code&gt; directory.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;dependencies' build (^build)
        │
        ├──▶ build ──▶ test
        │
        └──▶ typecheck

lint  (no deps, fully parallel)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;outputs&lt;/code&gt; array is also the entire cache contract. When Turborepo sees a task whose inputs (source files + lockfile + this &lt;code&gt;turbo.json&lt;/code&gt;) hash to something it has seen before, it replays the recorded &lt;code&gt;outputs&lt;/code&gt; instead of re-running the task - the "FULL TURBO" line in the terminal. I am not going to hand you a cache-hit percentage here; a third-party 2026 monorepo guide claims a specific number for Bun over pnpm, and that number is theirs, measured on their repo, not mine. The honest visual for this post is a real terminal capture of a cold &lt;code&gt;turbo run build&lt;/code&gt; followed by a warm one hitting the cache - no invented statistic attached.&lt;/p&gt;

&lt;h2&gt;
  
  
  Biome: lint and format in one config
&lt;/h2&gt;

&lt;p&gt;Biome replaces ESLint and Prettier with one Rust binary and one config file. &lt;code&gt;biome.json&lt;/code&gt;, trimmed to the linter and formatter blocks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;C:\_PROG\flare-engine-workspace\flare-engine\biome.json&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"$schema"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://biomejs.dev/schemas/1.9.0/schema.json"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"formatter"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"enabled"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"indentStyle"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"space"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"indentWidth"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"lineWidth"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"javascript"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"formatter"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"quoteStyle"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"single"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"trailingCommas"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"all"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"semicolons"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"always"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"linter"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"enabled"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"rules"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"recommended"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"correctness"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"noUnusedImports"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"noUnusedVariables"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"error"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"style"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"noNonNullAssertion"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"off"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"useConsistentArrayType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"level"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"options"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"syntax"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"shorthand"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"useImportType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"useSingleVarDeclarator"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"off"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"noDefaultExport"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"error"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;noUnusedImports&lt;/code&gt; and &lt;code&gt;noUnusedVariables&lt;/code&gt; are set to &lt;code&gt;error&lt;/code&gt;, not &lt;code&gt;warn&lt;/code&gt; - a dead import fails CI, it doesn't just clutter a report. &lt;code&gt;useImportType&lt;/code&gt; is also &lt;code&gt;error&lt;/code&gt;: every type-only import must say &lt;code&gt;import type&lt;/code&gt;, which matters a great deal in a monorepo where &lt;code&gt;verbatimModuleSyntax&lt;/code&gt; (next section) is on - a plain &lt;code&gt;import&lt;/code&gt; of a type-only binding is a build error, not a lint nit. &lt;code&gt;noNonNullAssertion&lt;/code&gt; is explicitly turned &lt;code&gt;off&lt;/code&gt; - the codebase uses &lt;code&gt;!&lt;/code&gt; deliberately in places where the type system can't see an invariant the engine guarantees at runtime. &lt;code&gt;noDefaultExport&lt;/code&gt; is set to &lt;code&gt;error&lt;/code&gt; as well, so the whole codebase is named-exports-only, with a small &lt;code&gt;overrides&lt;/code&gt; block that flips the rule back off for the handful of files that genuinely need a default export (config files like &lt;code&gt;*.config.ts&lt;/code&gt; and the Next.js app pages). One file. Lint and format both live here; there is no separate &lt;code&gt;.eslintrc&lt;/code&gt; and &lt;code&gt;.prettierrc&lt;/code&gt; to keep in sync, because there's nothing to keep in sync.&lt;/p&gt;

&lt;h2&gt;
  
  
  One tsconfig base, 40 extends
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;tsconfig.base.json&lt;/code&gt; is the config every package's TypeScript extends - the same one-config-governs-all rule, applied to type-checking:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;C:\_PROG\flare-engine-workspace\flare-engine\tsconfig.base.json&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"compilerOptions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"strict"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"target"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ES2022"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"module"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ESNext"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"moduleResolution"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bundler"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"verbatimModuleSyntax"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"types"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"bun-types"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"declaration"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"declarationMap"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"sourceMap"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"skipLibCheck"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"forceConsistentCasingInFileNames"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"resolveJsonModule"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"isolatedModules"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"esModuleInterop"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"noUncheckedIndexedAccess"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"exclude"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"node_modules"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"dist"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;strict&lt;/code&gt; and &lt;code&gt;noUncheckedIndexedAccess&lt;/code&gt; are the two settings that catch the most real bugs in practice - the second means &lt;code&gt;array[i]&lt;/code&gt; is typed &lt;code&gt;T | undefined&lt;/code&gt;, not &lt;code&gt;T&lt;/code&gt;, so every index access has to prove it checked bounds. &lt;code&gt;verbatimModuleSyntax&lt;/code&gt; is what makes Biome's &lt;code&gt;useImportType&lt;/code&gt; rule load-bearing rather than cosmetic: without it, a type-only import that isn't marked &lt;code&gt;import type&lt;/code&gt; can survive erasure incorrectly at the module boundary. Forty packages extend this one file and layer on their own &lt;code&gt;include&lt;/code&gt; paths - none of them re-declares &lt;code&gt;strict&lt;/code&gt; or re-decides whether indexed access is checked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Releasing in lockstep
&lt;/h2&gt;

&lt;p&gt;Changesets manages versioning, and the &lt;code&gt;.changeset/config.json&lt;/code&gt; makes a deliberate, debatable choice - &lt;strong&gt;lockstep&lt;/strong&gt;, not independent SemVer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;C:\_PROG\flare-engine-workspace\flare-engine\.changeset\config.json&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"$schema"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://unpkg.com/@changesets/config@3.0.0/schema.json"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"changelog"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"@changesets/changelog-github"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"repo"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"grzott/flare-engine"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"commit"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"fixed"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="s2"&gt;"@flare-engine/*"&lt;/span&gt;&lt;span class="p"&gt;]],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"linked"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"access"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"restricted"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"baseBranch"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"main"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"updateInternalDependencies"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"patch"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ignore"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"@flare-engine/benchmarks"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;fixed: [["@flare-engine/*"]]&lt;/code&gt; means every scoped package bumps together - all forty move from 0.2.0 to 0.2.1 in the same release, even if only &lt;code&gt;math&lt;/code&gt; changed. That is lazy by the independent-SemVer argument, and I'll name the tradeoff honestly: for a single-maintainer engine where consumers (Pan Tvardowski, the internal second game) always want the whole set moving in step, lockstep removes an entire class of "which version of &lt;code&gt;render&lt;/code&gt; works with which version of &lt;code&gt;ecs&lt;/code&gt;" support questions. If this were a multi-team OSS project with independent release cadences per package, I'd pick &lt;code&gt;linked&lt;/code&gt; groups or full independence instead.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;access: "restricted"&lt;/code&gt; is the other load-bearing field. &lt;code&gt;@flare-engine/*&lt;/code&gt; publishes to the private npm registry at version 0.2.1 today - &lt;strong&gt;restricted until the October 2026 Apache-2.0 launch.&lt;/strong&gt; Any example below that says &lt;code&gt;bun add @flare-engine/math&lt;/code&gt; needs that fine print attached, every time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Restricted until the Oct-2026 Apache-2.0 launch - not yet public npm&lt;/span&gt;
bun add @flare-engine/math
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ignore: ["@flare-engine/benchmarks"]&lt;/code&gt; excludes the private benchmarking package from versioning entirely - it lives outside &lt;code&gt;packages/&lt;/code&gt; and never ships.&lt;/p&gt;

&lt;p&gt;A per-package example, &lt;code&gt;packages/math/package.json&lt;/code&gt;, shows the four scripts every one of the forty packages carries:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;C:\_PROG\flare-engine-workspace\flare-engine\packages\math\package.json&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"@flare-engine/math"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"0.2.1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"scripts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"build"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tsup"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"test"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bun test"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"lint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"biome check src/"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"typecheck"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tsc --noEmit"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"publishConfig"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"access"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"restricted"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"registry"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://registry.npmjs.org/"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sideEffects"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same four scripts, every package, every time - &lt;code&gt;tsup&lt;/code&gt; for the build, &lt;code&gt;bun test&lt;/code&gt; for the tests, &lt;code&gt;biome check src/&lt;/code&gt; for lint, &lt;code&gt;tsc --noEmit&lt;/code&gt; for typecheck. Turborepo's whole job is fanning these four identical scripts out across forty package.json files in dependency order.&lt;/p&gt;

&lt;h2&gt;
  
  
  The guardrail that ties it together
&lt;/h2&gt;

&lt;p&gt;None of the above prevents a package from importing something one tier above it in the dependency graph - config alone doesn't enforce architecture. That enforcement is a custom script, &lt;code&gt;scripts/check-deps-tiers.mjs&lt;/code&gt;, wired into CI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// C:\_PROG\flare-engine-workspace\flare-engine\scripts\check-deps-tiers.mjs&lt;/span&gt;
&lt;span class="cm"&gt;/**
 * check-deps-tiers - CI guardrail for the @flare-engine/* dependency graph.
 *
 * Three checks over every packages/*:
 *   1. PHANTOM deps - declared in package.json (deps + peerDeps) but never
 *      imported from src (.ts/.tsx/.mts/.cts).
 *   2. MISSING deps - imported from src but not declared.
 *   3. UPWARD tier violations - a package depending on a strictly-higher tier,
 *      breaking the downward-only rule. `@flare-engine/engine` is the sanctioned
 *      umbrella barrel: it may depend on anything, but its own deps are still
 *      required to point downward from every OTHER package's perspective.
 *
 * Phantom and missing are both errors. Exit 1 on any violation, 0 when clean.
 * Bun-runnable plain ESM.
 */&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three checks, one script, &lt;code&gt;exit 1&lt;/code&gt; on any violation: phantom dependencies (declared but never imported - dead weight), missing dependencies (imported but never declared - the thing that silently works today and breaks the moment hoisting changes), and upward-tier violations (a lower-tier package reaching into a higher-tier one, breaking the downward-only dependency rule that keeps the whole graph acyclic). This is the mechanical enforcement of the same strict-layer discipline covered in &lt;a href="https://dev.to/blog/architecture-before-the-ai-build"&gt;Architecture before the AI build&lt;/a&gt; - that post argued for the rule; this script is what makes the rule impossible to violate by accident. The result, measured 2026-07-04: &lt;strong&gt;0 dependency cycles&lt;/strong&gt; across the full acyclic DAG.&lt;/p&gt;

&lt;p&gt;CI wires all of this into one ordered chain, &lt;code&gt;.github/workflows/ci.yml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# C:\_PROG\flare-engine-workspace\flare-engine\.github\workflows\ci.yml&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;CI&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;ci&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v5&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;fetch-depth&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;oven-sh/setup-bun@v2&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;bun-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;latest&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bun install --frozen-lockfile&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bun run lint&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bun run check:deps&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bun run check:changeset&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bun run typecheck&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bun run build&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bun run test&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;bun install --frozen-lockfile&lt;/code&gt; first - no silent lockfile drift on a PR build. Then six steps in a fixed order: &lt;code&gt;lint&lt;/code&gt; (fast, fully parallel, fails first if it's going to fail), &lt;code&gt;check:deps&lt;/code&gt; (the tier guardrail above), &lt;code&gt;check:changeset&lt;/code&gt; (did this PR remember to add a changeset), &lt;code&gt;typecheck&lt;/code&gt;, &lt;code&gt;build&lt;/code&gt;, &lt;code&gt;test&lt;/code&gt; - the slowest and most expensive check runs last, after everything cheaper has already had a chance to fail fast. This is engine-only CI: it exercises &lt;code&gt;lint&lt;/code&gt;/&lt;code&gt;typecheck&lt;/code&gt;/&lt;code&gt;build&lt;/code&gt;/&lt;code&gt;test&lt;/code&gt; on Ubuntu with &lt;code&gt;setup-bun&lt;/code&gt;, not on-device React Native builds, which run through a separate EAS pipeline outside this post's scope.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Bun, not pnpm.&lt;/strong&gt; Said once above, said again here because it's the easiest thing to get backwards reading about this stack out of context: flare-engine runs on Bun workspaces. pnpm 10 is a different repo (this portfolio). If you're deciding between the two for your own monorepo, don't let this post stand in for a real Bun-vs-pnpm benchmark - it isn't one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Versions drift fast.&lt;/strong&gt; Bun 1.2.0, Turborepo 2.9.6, Biome 1.9.4, TypeScript 5.9.3 are the resolved versions from &lt;code&gt;bun.lock&lt;/code&gt; on 2026-07-04. Bun 1.3, Turborepo 3.0, and Biome 2.2 are already out in the wild as this publishes. Treat every version number here as a dated snapshot, not a recommendation to pin.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No cache-hit percentage of my own.&lt;/strong&gt; I did not measure a cache-hit rate on this repo, and I'm not going to borrow the "~40% fewer cache misses vs pnpm" number from someone else's 2026 guide and present it as mine. The gallery above shows a real, unedited &lt;code&gt;turbo run build&lt;/code&gt; replay hitting FULL TURBO - that's the honest visual, with no percentage attached to it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Restricted npm.&lt;/strong&gt; &lt;code&gt;@flare-engine/*&lt;/code&gt; publishes &lt;code&gt;access: restricted&lt;/code&gt; at version 0.2.1 - private registry, not installable by the public - until the October 2026 Apache-2.0 launch. The repo's &lt;code&gt;LICENSE&lt;/code&gt; file says MIT today; Apache-2.0 is the plan for the pre-launch relicense, not the current state.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This is a tooling post, not an engine-internals post.&lt;/strong&gt; The framing here is a monorepo running a modular 2D engine for React Native + Web animation, gamification, and interactive UI - games are showcases built on it, not the product itself, and this isn't a game-engine-vs-Unity-or-Godot comparison. If you want the engine's internals, that's a different post.&lt;/p&gt;

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

&lt;p&gt;If you're setting up or cleaning up a TypeScript monorepo in 2026, the shape here scales down further than forty packages suggests. Two packages need almost none of this - you can skip the tier guardrail and the lockstep Changesets config until you actually have a dependency graph worth protecting. What doesn't scale down is the discipline of one config per tool: the moment you let a second package declare its own ESLint rules or its own tsconfig strictness, you've started the sprawl this whole stack exists to prevent.&lt;/p&gt;

&lt;p&gt;This toolchain is also the reason a new prototype package - a second consumer app, an experiment that might not ship - takes minutes to wire up instead of days: &lt;code&gt;bun add&lt;/code&gt;, extend the base tsconfig, and Turborepo already knows how to build, test, lint, and typecheck it in the right order. Clone the shape, not the exact numbers; your forty will look different from mine.&lt;/p&gt;

&lt;p&gt;If you want the layering rule this repo's CI mechanically enforces, &lt;a href="https://dev.to/blog/architecture-before-the-ai-build"&gt;Architecture before the AI build&lt;/a&gt; is the post that argues for it. If you want to see where this repo started - 36 packages, three weeks, the same strict-layer discipline before the guardrail script existed - that's &lt;a href="https://dev.to/blog/36-package-rn-game-engine-in-3-weeks"&gt;36 packages, 1 dev: a React Native game engine in 3 weeks&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/blog/36-package-rn-game-engine-in-3-weeks"&gt;I directed AI agents to build a 36-package RN game engine in 12 days - here is what shipped&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/blog/architecture-before-the-ai-build"&gt;Architecture before the AI build&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>typescript</category>
      <category>webdev</category>
      <category>tutorial</category>
      <category>showdev</category>
    </item>
    <item>
      <title>I built generator coroutines for my game engine, then didn't use them</title>
      <dc:creator>Grzegorz Otto</dc:creator>
      <pubDate>Tue, 23 Jun 2026 15:29:39 +0000</pubDate>
      <link>https://dev.to/grzott/i-built-generator-coroutines-for-my-game-engine-then-didnt-use-them-o3g</link>
      <guid>https://dev.to/grzott/i-built-generator-coroutines-for-my-game-engine-then-didnt-use-them-o3g</guid>
      <description>&lt;p&gt;Generator coroutines are the textbook fix for callback hell in game sequencing. Write a boss script or a cutscene as a straight-line &lt;code&gt;function*&lt;/code&gt; that &lt;code&gt;yield&lt;/code&gt;s to wait, instead of a pyramid of timers and callbacks. I built exactly that for my engine - &lt;code&gt;CoroutineManager&lt;/code&gt;, 122 lines, 12 tests, three yield kinds. Then, when it came time to actually script the bosses in the shipped game, I used a finite state machine instead and left the coroutine system unused on purpose.&lt;/p&gt;

&lt;p&gt;This post is about why. Two reasons that most TypeScript game coroutines tutorials never mention, because they don't matter until you ship a real-time, save-capable game.&lt;/p&gt;

&lt;h2&gt;
  
  
  The elegant case for TypeScript game coroutines
&lt;/h2&gt;

&lt;p&gt;The pitch for coroutines is real and fair. Consider scripting a boss that pauses, fires, waits two seconds, then fires again. With timers and callbacks you get something like this:&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;// pseudocode&lt;/span&gt;
&lt;span class="nf"&gt;startPhase&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="nf"&gt;fire&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nf"&gt;setTimeout&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="nf"&gt;fire&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nf"&gt;setTimeout&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="nf"&gt;startNextPhase&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;2000&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;With a generator coroutine, the same logic reads as a straight sequence:&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;// pseudocode&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;bossScript&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;fire&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="mf"&gt;2.0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// wait 2 seconds&lt;/span&gt;
  &lt;span class="nf"&gt;fire&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// wait 1 second&lt;/span&gt;
  &lt;span class="nf"&gt;startNextPhase&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 generator version is obviously better to read and modify. The sequencing matches the mental model of "first this, then that." It's the same pattern Unity uses - Unity coroutines are C# iterator methods (&lt;code&gt;IEnumerator&lt;/code&gt; + &lt;code&gt;yield return&lt;/code&gt;) started via &lt;code&gt;StartCoroutine&lt;/code&gt; and stopped via &lt;code&gt;StopCoroutine&lt;/code&gt;, with built-in yield instructions such as &lt;code&gt;WaitForSeconds&lt;/code&gt;, &lt;code&gt;WaitForEndOfFrame&lt;/code&gt;, &lt;code&gt;WaitUntil&lt;/code&gt;, and &lt;code&gt;WaitWhile&lt;/code&gt;. It's also why redux-saga, a widely-used Redux middleware, models side-effect orchestration with generator functions: a saga is a generator that yields plain JavaScript objects ("effects") describing what should happen, and the middleware interprets each effect and resumes the generator - a real-world precedent for generators as a sequencing model outside of games.&lt;/p&gt;

&lt;p&gt;The readability argument won me over. So I built the scheduler.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually built
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;CoroutineManager&lt;/code&gt; lives in &lt;code&gt;packages/core/src/coroutine.ts&lt;/code&gt; and clocks in at 122 lines. It tracks active coroutines in a list, ticks them on every &lt;code&gt;update(dt)&lt;/code&gt; call, and handles three yield kinds: &lt;code&gt;number&lt;/code&gt; (wait N seconds), &lt;code&gt;undefined&lt;/code&gt; (wait one frame), and &lt;code&gt;Promise&lt;/code&gt; (wait for async resolution).&lt;/p&gt;

&lt;p&gt;The heart of the scheduler is the &lt;code&gt;advance&lt;/code&gt; method. Here it is verbatim:&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/coroutine.ts&lt;/span&gt;
&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nf"&gt;advance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CoroutineState&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;gen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;next&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;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;done&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;done&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&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="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&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;typeof&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;number&lt;/span&gt;&lt;span class="dl"&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;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;waitTime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&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;value&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;waitingForPromise&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&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="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;waitingForPromise&lt;/span&gt; &lt;span class="o"&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;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="c1"&gt;// On rejection, stop the coroutine&lt;/span&gt;
        &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;done&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&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="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// undefined → wait 1 frame (will advance next update)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The system has 12 tests. The only callers are those tests - nothing in the game or the engine's other packages uses &lt;code&gt;CoroutineManager&lt;/code&gt;. Here is one of those test callsites, verbatim:&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/tests/coroutine.test.ts&lt;/span&gt;
&lt;span class="nx"&gt;mgr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;function&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;yield&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;reached&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&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;The ergonomics are clean. The tests pass. And then I chose not to wire it into the game.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reason 1: allocation on the hot path
&lt;/h2&gt;

&lt;p&gt;Look at &lt;code&gt;advance&lt;/code&gt; again. The first line is &lt;code&gt;const result = state.gen.next()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;By the ECMAScript specification, every generator &lt;code&gt;.next()&lt;/code&gt; returns a freshly created &lt;code&gt;{value, done}&lt;/code&gt; object. ECMA-262 §7.4.12 (&lt;code&gt;CreateIterResultObject&lt;/code&gt;) does &lt;code&gt;OrdinaryObjectCreate(%Object.prototype%)&lt;/code&gt; then sets the &lt;code&gt;value&lt;/code&gt; and &lt;code&gt;done&lt;/code&gt; properties and returns that new object. On top of that, invoking the generator function in the first place allocates the generator object itself.&lt;/p&gt;

&lt;p&gt;On Hermes - the JavaScript engine React Native uses - this result object is allocated on every &lt;code&gt;.next()&lt;/code&gt; with no JIT to elide it, so a manually-driven generator loop produces steady per-step GC pressure. On V8, TurboFan's escape analysis can sometimes eliminate the &lt;code&gt;iterResult&lt;/code&gt; allocation for plain &lt;code&gt;for-of&lt;/code&gt; loops, but not for hand-driven &lt;code&gt;.next()&lt;/code&gt; coroutine loops like this one. So on both engines you get a stream of small, short-lived objects accumulating across every frame.&lt;/p&gt;

&lt;p&gt;This is not a hard per-frame performance cliff. Minor GC of young objects is cheap. The point is that it is non-zero allocation on a path engineered for zero allocation. The engine's frame loop is designed around zero per-frame heap allocation - typed arrays for component data, pooled event objects, pre-allocated collision structures. Running a coroutine scheduler in that loop introduces steady GC pressure from a source that compounds with every additional concurrent sequence.&lt;/p&gt;

&lt;p&gt;No measured profiling number exists for this path, because I never wired the coroutines into the loop. The allocation argument is a design reason grounded in how generators work - not a benchmark.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reason 2: you can't serialize a paused generator
&lt;/h2&gt;

&lt;p&gt;The harder dealbreaker is serialization.&lt;/p&gt;

&lt;p&gt;A paused JavaScript generator cannot be serialized. Its resume state lives in spec-internal slots - &lt;code&gt;[[GeneratorState]]&lt;/code&gt; and &lt;code&gt;[[GeneratorContext]]&lt;/code&gt;, the latter holding the execution context with the suspended instruction position and live scope. These are not exposed to user code and have no defined serialization form. Call &lt;code&gt;structuredClone&lt;/code&gt; on a generator and you get a &lt;code&gt;DataCloneError&lt;/code&gt; on the closure. Call &lt;code&gt;JSON.stringify&lt;/code&gt; and you get &lt;code&gt;{}&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;For a game engine that needs to support save states, replays, or deterministic tests, this matters. The sequence's progress must be plain, restorable data - something you can write to disk and reload. A generator's "where am I" is a black box.&lt;/p&gt;

&lt;p&gt;The FSM solves this directly. The boss's state is a string field on the &lt;code&gt;Boss&lt;/code&gt; component - &lt;code&gt;b.phase&lt;/code&gt;. On every update, the system reads that string and drives behavior from it. Here is the serialization tell, verbatim from the real boss FSM:&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;// Pan-Tvardowski/src/systems/boss/fsm.ts&lt;/span&gt;
&lt;span class="nx"&gt;runtime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;deps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;deps&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;currentName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;runtime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fsm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;name&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;currentName&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;phase&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;runtime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fsm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&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;phase&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;b.phase&lt;/code&gt; is the entire state. To save the game, serialize &lt;code&gt;b.phase&lt;/code&gt;. To load, set &lt;code&gt;b.phase&lt;/code&gt; and call &lt;code&gt;fsm.start(b.phase)&lt;/code&gt; - the FSM reconstructs from that string. A paused generator has no equivalent. You would have to re-implement explicit progress tracking anyway, at which point you have reinvented the FSM.&lt;/p&gt;

&lt;h2&gt;
  
  
  What won and why
&lt;/h2&gt;

&lt;p&gt;The finite state machine won on the two axes that matter for a real-time, save-capable game.&lt;/p&gt;

&lt;p&gt;Its authoritative state is a string on a component - zero per-frame allocation, trivially serializable. The FSM primitive itself is 76 lines in &lt;code&gt;packages/ecs/src/fsm.ts&lt;/code&gt;. The PT boss system uses it today: phase edges drive transitions, cooldown-driven fire callbacks handle the in-phase cadence. There is no &lt;code&gt;function*&lt;/code&gt; anywhere in the boss code.&lt;/p&gt;

&lt;p&gt;A zero-allocation data-driven timeline stepper - a planned next refinement - would let me express a phase's beat sequence as an array of pattern steps rather than procedural callbacks. That is planned, not shipped. Today the pattern is FSM + fire callbacks.&lt;/p&gt;

&lt;p&gt;Here is the comparison at a glance:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Property&lt;/th&gt;
&lt;th&gt;Generator coroutine&lt;/th&gt;
&lt;th&gt;Finite state machine&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Readability of a sequence&lt;/td&gt;
&lt;td&gt;Excellent - straight-line &lt;code&gt;function*&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Moderate - phase edges + callbacks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Per-frame allocation&lt;/td&gt;
&lt;td&gt;One generator object + one &lt;code&gt;{value,done}&lt;/code&gt; per step&lt;/td&gt;
&lt;td&gt;Zero&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Serializable state&lt;/td&gt;
&lt;td&gt;No - internal slots only&lt;/td&gt;
&lt;td&gt;Yes - a string on a component&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cancellation semantics&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;return()&lt;/code&gt; on the generator&lt;/td&gt;
&lt;td&gt;State transition to an end state&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best fit&lt;/td&gt;
&lt;td&gt;Non-hot-path async, once-through flows&lt;/td&gt;
&lt;td&gt;Real-time game logic with save requirements&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;As a decision tree:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Game sequencing need
│
└─ In the frame loop?
   ├─ No  → Generator coroutine
   └─ Yes → Must survive a save?
            ├─ Yes → Finite state machine
            └─ No  → Many concurrent sequences?
                     ├─ Yes → Finite state machine
                     └─ No  → Generator coroutine
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;No benchmark exists for the coroutine path. The allocation argument is qualitative, grounded in spec behavior and the engine's zero-alloc discipline - not a measured fps delta. On V8, escape analysis may partially elide the &lt;code&gt;iterResult&lt;/code&gt; allocation in some loops; on Hermes, it does not. Either way, the serialization reason stands independently.&lt;/p&gt;

&lt;p&gt;The FSM is not a readability win over coroutines. Boss scripts expressed as phase edges and fire callbacks are less obviously sequential than a &lt;code&gt;function*&lt;/code&gt;. That trade-off is real. If readability were the only axis, coroutines would have won.&lt;/p&gt;

&lt;p&gt;The engine is published to npm as &lt;code&gt;@flare-engine/*&lt;/code&gt; version 0.1.0 with restricted access - it is not open source at this stage. The &lt;code&gt;CoroutineManager&lt;/code&gt; ships in &lt;code&gt;@flare-engine/core&lt;/code&gt;; it is simply unused by the game.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where coroutines do belong
&lt;/h2&gt;

&lt;p&gt;The coroutine system is not a mistake. It is the right tool on a different layer.&lt;/p&gt;

&lt;p&gt;For non-hot-path async - scripting an onboarding flow in a React Native app, sequencing an asset or boot pipeline, running any once-through async glue that is not in the frame loop and does not need to survive a save - a generator coroutine is excellent. The readability benefit is real. The allocation and serialization costs do not apply to a flow that runs once, on startup, outside the 60 fps tick.&lt;/p&gt;

&lt;p&gt;This is the exact example for the React Native prototyping angle: generators are how you script onboarding flows without callback hell in React Native. The mistake is not reaching for generators; the mistake is wiring them into the frame loop of a save-capable game and pretending the trade-offs aren't there.&lt;/p&gt;

&lt;p&gt;Right tool, wrong layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/blog/zero-alloc-game-loops-in-typescript"&gt;Zero-alloc game loops in TypeScript&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/blog/the-react-native-game-engine-gap"&gt;The React Native game engine gap in 2026&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Originally posted on &lt;a href="https://grzegorzotto.dev/blog/typescript-game-coroutines-i-didnt-use" rel="noopener noreferrer"&gt;grzegorzotto.dev&lt;/a&gt;&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>gamedev</category>
      <category>react</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Archetype ECS in TypeScript: why I didn't use bitECS or miniplex</title>
      <dc:creator>Grzegorz Otto</dc:creator>
      <pubDate>Wed, 10 Jun 2026 04:42:55 +0000</pubDate>
      <link>https://dev.to/grzott/archetype-ecs-in-typescript-why-i-didnt-use-bitecs-or-miniplex-22cb</link>
      <guid>https://dev.to/grzott/archetype-ecs-in-typescript-why-i-didnt-use-bitecs-or-miniplex-22cb</guid>
      <description>&lt;p&gt;Everyone says "use an ECS." Nobody tells you the real decision is how it stores components. That choice shapes the API, the performance envelope, and the limits you'll hit two years later.&lt;/p&gt;

&lt;p&gt;The TypeScript ecosystem has two well-built options. &lt;strong&gt;&lt;a href="https://github.com/NateTheGreatt/bitECS" rel="noopener noreferrer"&gt;bitECS&lt;/a&gt;&lt;/strong&gt; goes struct-of-arrays: each component field is a &lt;code&gt;TypedArray&lt;/code&gt; indexed by entity id. It's the throughput pick - designed for high entity counts (tens of thousands and up), and it serializes efficiently because the bytes are contiguous typed arrays. &lt;strong&gt;&lt;a href="https://github.com/hmans/miniplex" rel="noopener noreferrer"&gt;miniplex&lt;/a&gt;&lt;/strong&gt; goes object-based: entities are plain objects, queries are archetype buckets that re-index on add/remove, and the TypeScript integration is the friendliest I've seen. If you want React bindings out of the box, miniplex 2.x is the one.&lt;/p&gt;

&lt;p&gt;I used neither for &lt;code&gt;@flare-engine/ecs&lt;/code&gt;. I wrote a third approach - bitmask archetypes plus per-component Maps - and accepted one hard limit to get there. This post is about that trade-off.&lt;/p&gt;

&lt;h2&gt;
  
  
  What an archetype ECS TypeScript implementation actually decides
&lt;/h2&gt;

&lt;p&gt;An ECS is three things: entities (integer IDs), components (plain data attached to entities), and systems (functions that run over "every entity with components X and Y").&lt;/p&gt;

&lt;p&gt;The part libraries differ on is the last word: &lt;strong&gt;how do you find all entities with components X and Y?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The naive answer is to scan every entity and check. That is O(n) in entity count and dies at scale. The productive answer is to maintain an index so "give me all entities with Position and Velocity" is nearly free. How you build that index is the design decision.&lt;/p&gt;

&lt;p&gt;There are three ways to build that index:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Data layout&lt;/th&gt;
&lt;th&gt;Wins&lt;/th&gt;
&lt;th&gt;Costs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;bitECS&lt;/strong&gt; - struct-of-arrays&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Position.x: Float32Array&lt;/code&gt;, &lt;code&gt;Position.y: Float32Array&lt;/code&gt;, indexed by entityId&lt;/td&gt;
&lt;td&gt;cache-local, scales to 100k&lt;/td&gt;
&lt;td&gt;data = parallel arrays&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;miniplex&lt;/strong&gt; - object buckets&lt;/td&gt;
&lt;td&gt;archetype bucket: &lt;code&gt;[{pos, vel}, ...]&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;great DX + React&lt;/td&gt;
&lt;td&gt;object-heap iteration, GC pressure at scale&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;flare-engine&lt;/strong&gt; - bitmask + Maps&lt;/td&gt;
&lt;td&gt;entity 3 → archetype &lt;code&gt;0b011&lt;/code&gt;; per-component stores &lt;code&gt;Map{3 → {x,y}}&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;readable in debugger, one bitmask-AND match&lt;/td&gt;
&lt;td&gt;32-component ceiling&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Struct-of-arrays (bitECS).&lt;/strong&gt; Every field of every component is a typed array indexed by entity ID. &lt;code&gt;Position.x[3]&lt;/code&gt; and &lt;code&gt;Position.y[3]&lt;/code&gt; are the position of entity 3. CPU cache loves this - you iterate a contiguous block of floats. The cost: your data is parallel arrays, not objects. You can't &lt;code&gt;console.log&lt;/code&gt; an entity and see its components. Serialization is a typed-array snapshot. This is the right shape when you're at 10k+ entities and throughput is the primary constraint.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Object archetype buckets (miniplex).&lt;/strong&gt; Entities are plain objects. miniplex uses an archetype model - archetypes in 1.x, reworked into the &lt;code&gt;.with()&lt;/code&gt; / &lt;code&gt;.without()&lt;/code&gt; query API in 2.x: entities with the same component set land in the same bucket, so a query iterates one array of objects rather than all entities. Great DX, strong TypeScript narrowing, first-class React integration. The cost: you're iterating object heap references, which is harder for the CPU to prefetch, and GC pressure increases with churn. At hundreds of entities this is invisible. At 50k with frequent add/remove it starts to matter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bitmask archetypes plus per-component Maps (flare-engine).&lt;/strong&gt; Each component type gets a unique integer ID and a bit (&lt;code&gt;1 &amp;lt;&amp;lt; id&lt;/code&gt;). An entity's current component set is stored as a single &lt;code&gt;number&lt;/code&gt; - its archetype bitmask. Component data lives in &lt;code&gt;Map&amp;lt;entityId, data&amp;gt;&lt;/code&gt; per component type. "Does entity 3 have Position and Velocity?" is &lt;code&gt;(archetype3 &amp;amp; mask) === mask&lt;/code&gt; - one bitwise AND. The cost: Map indirection is not cache-local, and the 32-bit JavaScript number limits you to 32 component types.&lt;/p&gt;

&lt;h2&gt;
  
  
  The flare-engine code
&lt;/h2&gt;

&lt;p&gt;The whole ECS is eight files. Here are the three that carry the bulk of the design.&lt;/p&gt;

&lt;h3&gt;
  
  
  Component definition
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// packages/ecs/src/component.ts&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;nextComponentId&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;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;defineComponent&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;=&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;defaults&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;ComponentDef&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;nextComponentId&lt;/span&gt;&lt;span class="o"&gt;++&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;id&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Maximum 32 component types exceeded (defining "&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&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="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;bit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;create&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;defaults&lt;/span&gt; &lt;span class="o"&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="kc"&gt;undefined&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;T&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;&lt;code&gt;defineComponent&lt;/code&gt; hands out sequential IDs and computes a bit. When &lt;code&gt;id&lt;/code&gt; hits 32, &lt;code&gt;1 &amp;lt;&amp;lt; 32&lt;/code&gt; wraps to 1 in JavaScript - a silent collision - so the &lt;code&gt;throw&lt;/code&gt; is load-bearing, not defensive. Tag components (no data, just presence) pass no &lt;code&gt;defaults&lt;/code&gt;; the returned &lt;code&gt;create()&lt;/code&gt; returns &lt;code&gt;undefined&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  World - storage and mutation
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// packages/ecs/src/world.ts&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;World&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nx"&gt;archetypes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;       &lt;span class="c1"&gt;// entityId → bitmask&lt;/span&gt;
  &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nx"&gt;componentStores&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&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;unknown&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// componentId → (entityId → data)&lt;/span&gt;

  &lt;span class="nx"&gt;add&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="nx"&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="nx"&gt;def&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ComponentDef&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="nx"&gt;data&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;arch&lt;/span&gt; &lt;span class="o"&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;archetypes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entityId&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;arch&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="k"&gt;return&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;store&lt;/span&gt; &lt;span class="o"&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;componentStores&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;def&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;store&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Map&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;componentStores&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;def&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nx"&gt;store&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;entityId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&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="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;def&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&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;newArch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;arch&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="nx"&gt;def&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bit&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;          &lt;span class="c1"&gt;// OR in the bit&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;archetypes&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;entityId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;newArch&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;const&lt;/span&gt; &lt;span class="nx"&gt;query&lt;/span&gt; &lt;span class="k"&gt;of&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;queries&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;      &lt;span class="c1"&gt;// notify live queries&lt;/span&gt;
      &lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entityId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;newArch&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The JSDoc on &lt;code&gt;World&lt;/code&gt; says explicitly: "Archetype bitmasks enable fast query matching." &lt;code&gt;add&lt;/code&gt; ORs the component's bit into the entity's archetype, then calls &lt;code&gt;_check&lt;/code&gt; on every registered query. &lt;code&gt;remove&lt;/code&gt; ANDs the inverted bit out: &lt;code&gt;arch &amp;amp; ~def.bit&lt;/code&gt;. &lt;code&gt;has&lt;/code&gt; is &lt;code&gt;(arch &amp;amp; def.bit) !== 0&lt;/code&gt; - a single expression.&lt;/p&gt;

&lt;h3&gt;
  
  
  Query - the bitmask match
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// packages/ecs/src/query.ts&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;Query&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;requiredMask&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;readonly&lt;/span&gt; &lt;span class="nx"&gt;excludedMask&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;private&lt;/span&gt; &lt;span class="nx"&gt;entities&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="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="nx"&gt;entitySet&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;  &lt;span class="c1"&gt;// O(1) membership&lt;/span&gt;

  &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;onEntityAdded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Signal&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;number&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;readonly&lt;/span&gt; &lt;span class="nx"&gt;onEntityRemoved&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Signal&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="nf"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;archetype&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;boolean&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="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;archetype&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&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;requiredMask&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&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;requiredMask&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
      &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;archetype&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&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;excludedMask&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="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nf"&gt;_check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&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="nx"&gt;archetype&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isMatch&lt;/span&gt; &lt;span class="o"&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;matches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;archetype&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;wasMatch&lt;/span&gt; &lt;span class="o"&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;entitySet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entityId&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;isMatch&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;wasMatch&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;entitySet&lt;/span&gt;&lt;span class="p"&gt;.&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;entityId&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;entities&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;entityId&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;onEntityAdded&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entityId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;isMatch&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;wasMatch&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;entitySet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entityId&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;entities&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;splice&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;entities&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;indexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entityId&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;1&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;onEntityRemoved&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entityId&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;matches&lt;/code&gt; is the whole archetype-query engine. Everything else is bookkeeping.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;_check&lt;/code&gt; keeps &lt;code&gt;entities[]&lt;/code&gt; and &lt;code&gt;entitySet&lt;/code&gt; in sync and fires &lt;code&gt;onEntityAdded&lt;/code&gt; / &lt;code&gt;onEntityRemoved&lt;/code&gt; Signals. Those signals are what let &lt;code&gt;@flare-engine/react&lt;/code&gt; subscribe to query membership changes without polling - the React side calls &lt;code&gt;useEffect&lt;/code&gt; on the signal and gets notified when enemies spawn or despawn, no manual diffing.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;world.query(Position, Velocity)&lt;/code&gt; builds a &lt;code&gt;requiredMask&lt;/code&gt; by ORing the bits of the passed components and constructs a &lt;code&gt;Query(mask, 0)&lt;/code&gt;. The &lt;code&gt;.queryBuilder().with(A).without(B).build()&lt;/code&gt; path composes &lt;code&gt;requiredMask&lt;/code&gt; and &lt;code&gt;excludedMask&lt;/code&gt; separately.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this shape for this engine
&lt;/h2&gt;

&lt;p&gt;Three reasons.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Readability.&lt;/strong&gt; &lt;code&gt;world.query(Position, Velocity)&lt;/code&gt; reads like English. The underlying data - &lt;code&gt;Map&amp;lt;number, {x: number, y: number}&amp;gt;&lt;/code&gt; - prints as a plain object in any debugger or &lt;code&gt;console.log&lt;/code&gt;. I spent a significant portion of the engine's early development reading entity state in the Bun REPL. Parallel typed arrays are opaque there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reactive queries.&lt;/strong&gt; The &lt;code&gt;Signal&lt;/code&gt;-based &lt;code&gt;onEntityAdded&lt;/code&gt; / &lt;code&gt;onEntityRemoved&lt;/code&gt; pair means the React layer and the ECS layer share membership information without a polling bridge. When Pan Tvardowski's bullet-spawner adds a &lt;code&gt;Bullet&lt;/code&gt; component, every query that requires &lt;code&gt;Bullet&lt;/code&gt; reacts immediately - the lifetime/cleanup sweep, the bullet alpha-pulse pass, the devtools snapshot counter. No per-frame scan.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Simplicity of the match.&lt;/strong&gt; A single AND covers "has all required components and none of the excluded ones." Every query - no matter how many with/without clauses - reduces to the same fixed two-operation test.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers - measured, off-device
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;ecs.step&lt;/code&gt; microbench runs &lt;code&gt;world.step(dt)&lt;/code&gt; with a single move system (read Position + Velocity, write Position) on Bun, off-device. These numbers measure the ECS step in isolation - not on-device frame time, not the full game loop.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Entities&lt;/th&gt;
&lt;th&gt;Step cost&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;500&lt;/td&gt;
&lt;td&gt;11.51 µs&lt;/td&gt;
&lt;td&gt;86.9K ops/sec&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1,000&lt;/td&gt;
&lt;td&gt;23.7 µs&lt;/td&gt;
&lt;td&gt;42.2K ops/sec&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2,000&lt;/td&gt;
&lt;td&gt;46.70 µs&lt;/td&gt;
&lt;td&gt;21.4K ops/sec&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5,000&lt;/td&gt;
&lt;td&gt;119.68 µs&lt;/td&gt;
&lt;td&gt;8.4K ops/sec&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The curve is linear - roughly 24 ns per entity, no cliff. Pan Tvardowski runs on the order of hundreds of live entities at peak. At that scale the ECS step is well under a millisecond.&lt;/p&gt;

&lt;p&gt;The more honest benchmark is the full integration frame: 1,000 entities plus 300 particles, measured off-device on Bun, costs 9.01 ms - and the ECS step is a small slice of that. On a Samsung Galaxy A54 5G under heavy load (50 enemies, 300 bullets), the game drops to around 48 fps. That cliff is not the ECS. The engine's own analysis points to &lt;code&gt;CollisionWorld.step&lt;/code&gt; - physics broadphase, not entity queries - as the bottleneck at that load (~350 bodies: 50 enemies + 300 bullets), where the off-device physics microbench crosses its 500-AABB threshold and the per-step cost extrapolates to most of the frame budget. The ECS was never my bottleneck, so I optimized it for readability rather than for a throughput crown I didn't need.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Caveat on the external numbers.&lt;/strong&gt; The &lt;a href="https://github.com/noctjs/ecs-benchmark" rel="noopener noreferrer"&gt;noctjs/ecs-benchmark&lt;/a&gt; suite reports bitECS at ~335K ops/sec (packed_5 scenario) and miniplex at ~109K ops/sec. Those numbers come from a different harness measuring a different operation - I cannot put "23.7 µs" and "335K ops/sec" in the same sentence as if they're comparable. What they do tell you: struct-of-arrays is built for throughput at scale. I wasn't at scale.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits - where this choice is wrong
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The 32-component ceiling is real and hard.&lt;/strong&gt; &lt;code&gt;1 &amp;lt;&amp;lt; id&lt;/code&gt; on a JavaScript number is 32-bit. Past 32 component types you get a silent bit collision - the &lt;code&gt;throw&lt;/code&gt; in &lt;code&gt;defineComponent&lt;/code&gt; is the only guard. If your game or simulation has 40+ component types, this architecture forces you to either pack flags into a single component, move to a &lt;code&gt;BigInt&lt;/code&gt; multi-word mask, or switch to bitECS. For Pan Tvardowski, the current count is well under 32; I've never approached the wall. But I know where it is.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No cache locality.&lt;/strong&gt; &lt;code&gt;Map&amp;lt;entityId, data&amp;gt;&lt;/code&gt; indirection is the opposite of bitECS's whole point. Each &lt;code&gt;world.get(entityId, Position)&lt;/code&gt; is a Map lookup - a hash, a pointer follow, a heap read. At 10k+ entities with a tight system loop this compounds into measurable latency. The envelope for this architecture is hundreds-to-low-thousands of entities. The engine's design notes float a struct-of-arrays path as a possible next step if I outgrow Maps - but it doesn't ship yet. Per-component Maps are the only storage path today. SoA is what I'd reach for if the benchmarks started showing a cliff.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No serialization story.&lt;/strong&gt; bitECS can snapshot a game state by cloning typed arrays - the bytes are contiguous and typed. Maps of plain objects require a custom serializer. Pan Tvardowski sidesteps this differently: it uses a seeded deterministic RNG for reproducible benchmark runs rather than serializing live state. If you need save-state or netcode out of the box, that's a point for bitECS.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No multi-threading.&lt;/strong&gt; becsy is the ECS being built around multi-threading - though as of 0.15.x that multi-threaded execution is still on its roadmap, not yet shipped. This one is single-threaded, and makes no attempt otherwise.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern beyond games
&lt;/h2&gt;

&lt;p&gt;Archetype queries are not game-specific. They are indexed sets of things matching a shape. If you have a kanban board with thousands of cards that can be "blocked", "overdue", "assigned", "flagged" - a bitmask-per-card plus a query for "overdue AND assigned AND NOT blocked" is an O(1) per-card match plus a push-based reactive update. The same pattern applies to chat messages with read/pinned/flagged states, to map markers with type/visible/selected/clustered states, to live feeds where items enter and leave "above-the-fold" visibility.&lt;/p&gt;

&lt;p&gt;The ECS framing is the cue: when you're managing thousands of homogeneous items and the interesting logic is "do something to all items matching these conditions," archetype queries are a legitimate state model regardless of whether you're rendering sprites or rendering a React list.&lt;/p&gt;

&lt;p&gt;The allocation discipline that makes this safe in a hot loop is a separate piece - the &lt;a href="https://grzegorzotto.dev/blog/zero-alloc-game-loops-in-typescript" rel="noopener noreferrer"&gt;zero-alloc game loops post&lt;/a&gt; covers why object storage stays harmless in a hot loop only when the move system pools and mutates in place, never allocating inside &lt;code&gt;step&lt;/code&gt;. And the &lt;a href="https://grzegorzotto.dev/blog/skia-atlas-2000-sprites-react-native" rel="noopener noreferrer"&gt;Skia Atlas post&lt;/a&gt; shows the other end of the frame: the ECS query for renderable entities feeds the SpriteBatch that draws them in a single draw call.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://grzegorzotto.dev/blog/36-package-rn-game-engine-in-3-weeks" rel="noopener noreferrer"&gt;I directed AI agents to build a 36-package RN game engine in 12 days - here is what shipped&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://grzegorzotto.dev/blog/the-react-native-game-engine-gap" rel="noopener noreferrer"&gt;The React Native game engine gap in 2026&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Originally posted on &lt;a href="https://grzegorzotto.dev/blog/archetype-ecs-typescript" rel="noopener noreferrer"&gt;grzegorzotto.dev&lt;/a&gt;&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%2Fwefr2kadydutzyar8wy0.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%2Fwefr2kadydutzyar8wy0.png" alt="Pan Tvardowski game showcase image" width="584" height="1188"&gt;&lt;/a&gt;&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>typescript</category>
      <category>gamedev</category>
      <category>gameengine</category>
    </item>
    <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>
