Kieran Crown
Blog

Running my React Native game engine in the browser

The same render.ts that ships on device also runs in a Next.js app on CanvasKit. It powers a level editor, an obstacle lab and the App Store screenshots.

4 min read
React NativeSkiaToolingWebAssemblyOverbloom

Overbloom has a companion tool called Studio. It runs at localhost:4477, it is a Next.js app, and it renders the game using the exact same render.ts that the device build uses. Not a reimplementation, not lookalike art. The same file.

It started because tuning obstacle formations on a phone is miserable. You edit a number, rebuild, launch, play until the formation spawns, decide it is wrong, and repeat. In a browser you edit a number and it is on screen. That gap gets expensive fast when the thing you are tuning is a matter of taste and you need a hundred iterations.

Why this is possible at all

The engine is plain TypeScript. update.ts takes a state object and a delta and mutates the state. render.ts takes a Skia canvas and a state and draws. Neither imports React, neither knows about React Native, and neither reaches for a platform API.

The only real dependency is Skia. And Skia has a WebAssembly build, CanvasKit, that React Native Skia already ships a web fork for. So the question is not whether the engine can run in a browser. It is whether you can convince the bundler to hand it the right Skia.

The shim

That turned out to be the whole problem, and the answer is one file plus a tsconfig path alias that points the bare specifier at it.

tools/studio/src/lib/engine-skia-shim.ts
// Lightweight replacement for `@shopify/react-native-skia` on the studio web
// bundle. tsconfig `paths` aliases the bare specifier to THIS file so the game
// engine resolves its six runtime imports here instead of dragging in the full
// RN Skia barrel (React reconciler, native views, DOM tree, reanimated).
 
import { Skia as WebSkia } from "@shopify/react-native-skia/lib/module/skia/Skia.web";
import { isRect } from "@shopify/react-native-skia/lib/module/skia/types";

Note the explicit .web path. This is the detail that cost me an evening:

tools/studio/src/lib/engine-skia-shim.ts
// We import the WEB forks by EXPLICIT path rather than relying on `.web.js`
// extension priority. Turbopack does not apply resolveExtensions to a package's
// own internal relative imports, so bare `./Skia` would otherwise resolve to
// the NATIVE Skia.js (which reads a `SkiaApi` global that only exists on
// device).

The failure mode is confusing because it looks like the web build is broken. The package resolves its own internals relative to itself, your bundler’s platform extension config does not reach inside, and you end up with the native implementation reaching for a global that will never exist.

createPicture had to be ported by hand for the same reason. The real one lives in a module that imports the bare ../Skia, which resolves to the native fork, so re-exporting it drags the device implementation in. The shim contains a verbatim port of the body compiled against the web Skia object instead.

One feature genuinely does not survive the trip. RuntimeShaderBuilder throws on React Native Web, and the black hole effect calls it at module scope, which would abort the entire engine import before anything renders. The shim degrades it to null:

tools/studio/src/lib/engine-skia-shim.ts
// The engine only uses that builder for the blackhole backdrop-lens, and every
// use is guarded by `if (BH_WARP_BUILDER)`. So degrade it to null: the lens is
// skipped on web, but the dead core, the accretion ring and the swirl still
// render. The lensing is a documented web-preview gap; the on-device pass is
// the sign-off for it.

Being explicit about the gap matters. A tool that is almost accurate is worse than one with a known hole in it, because you stop knowing which of your judgements to trust.

Load order is not optional

Several engine modules compile shaders when they are evaluated, not when they are called. Skia.RuntimeEffect.Make runs at import time in the black hole module, and in a couple of the theme and cosmetic modules. If the engine is imported before CanvasKit has loaded, those calls compile against an undefined API.

So nothing imports the engine directly. Everything goes through a loader that brings up CanvasKit first and then dynamically imports one barrel:

tools/studio/src/lib/engine.ts
// The one barrel the Obstacle Lab imports the real game engine through. Pages
// and the harness NEVER import `@/game/*` directly; they call `loadEngine()`,
// which loads CanvasKit and then dynamically imports THIS module.
//
// Pixel-identical to the app: this is the same render.ts / update.ts the device
// build runs, no lookalike reimplementation.
 
export { render, drawTrail, drawOrb, drawCoin, drawPowerup, /* ... */ } from "@/game/render";
export { update, updateJuice } from "@/game/update";

What it grew into

The obstacle lab was the first thing and the justification for all of it. Spawn any hazard, scrub time, watch a formation resolve without a rebuild.

The formation editor came next, including a sampler that turns an SVG path into a set of spawn positions, so a shape drawn in a vector editor becomes an obstacle arrangement.

The pose studio drives the real simulation forward to populate a field with real spawns, then freezes it so objects can be dragged into place. Every control is a call into the same headless API the in-app studio uses, so the two cannot drift apart.

Then the audio tab, which reads the sound registries straight out of src/game/sfx.ts and flags any placeholder that is still silent.

And finally the store screenshot compositor, which is a longer story of its own in App Store screenshots from the live engine.

None of those were planned. They all became cheap once the engine ran somewhere with a fast refresh loop and a real cursor.

Would I do it again

Yes, and earlier.

The precondition is having an engine that does not depend on its host, and that is worth wanting for other reasons. The same property lets the simulation run headless in a Node script, which is how I found a memory leak that only appeared after twenty minutes of play.

The cost is real but bounded. It is one shim file, one loader that respects import order, and a documented list of what does not work on web. That is a smaller ongoing tax than maintaining a second renderer that slowly stops matching the game, which is the usual alternative.

The game the tooling built is on the App Store.