A 120fps game in React Native, where the JS thread never touches a frame
Overbloom runs its simulation and its rendering entirely in UI-thread worklets. Here is the architecture, and what you give up to get there.
The standard advice is that React Native is the wrong tool for a game. I shipped one anyway. Overbloom is an arcade runner built on Expo, and it holds 120fps on a 120Hz panel with particles, shaders and a live physics sim on screen.
The advice is not wrong about the reason, though. It is wrong about which part is slow. JavaScript is not the bottleneck for a game like this. Crossing between JavaScript and the native UI sixty or a hundred and twenty times a second is. So the architecture is built around one rule: during a run, the JS thread does not participate in a frame at all.
Where the game actually lives
The entire game state is a single plain object held in a Reanimated shared value. It is created once and then mutated in place forever.
const game = useSharedValue<GameState>(useMemo(() => makeInitialState(worldH), []));Two functions operate on it. update(state, pointer, dt, hsv) advances the
simulation, and render(canvas, state, ...) draws it. Both are worklets, so
both are compiled to run on the UI thread. Neither one is a React component, and
neither one triggers a render.
The frame loop is a useFrameCallback:
useFrameCallback((info) => {
'worklet';
const raw = info.timeSincePreviousFrame ?? 16.67;
const dt = Math.min(0.033, Math.max(0, raw / 1000));
const g = game.value;
update(g, pointer.value, dt, worldHSV.value);
// ... drain discrete events to the JS thread (see below)
clock.value = info.timeSinceFirstFrame;
});That dt clamp matters more than it looks. If the app stalls for half a second,
an unclamped delta advances the world half a second in one step and the player
teleports into an obstacle that was never on screen. Capping at 33ms means a
stall costs you frames but never costs you a life.
Drawing hangs off a derived value that records a Skia Picture:
const picture = useDerivedValue(() => {
'worklet';
clock.value; // establishes the per-frame dependency
const g = game.value;
return createPicture((canvas) => {
render(canvas, g, paints, fonts, scaleSV.value, /* ... */);
}, { width: screenWSV.value, height: screenHSV.value });
});The clock.value; line looks like dead code and gets flagged by the linter. It
is load-bearing. game is mutated in place, so from Reanimated’s point of view
it never changes and the derived value would never recompute. The clock is a
counter that gets bumped at the end of every frame purely to say “something
happened, redraw.”
There is a real benefit hiding in that. When nothing is animating, nothing bumps the clock, and the whole record-and-rasterize pass is skipped. A paused game costs almost nothing.
The frame path allocates nothing
Hermes has a generational garbage collector. It is good, but it still stops the world, and a stop-the-world pause during a run shows up as a dropped frame. At 120fps the entire budget for a frame is 8.3ms, so there is no room for a collection you did not plan.
So the frame path allocates nothing. Every entity that can spawn during play comes out of a fixed pool that was built at startup:
export const POOL = { particles: 256, popups: 24, rings: 16, bullets: 16 } as const;Spawning means finding an inactive slot and overwriting its fields. There is no
push, no object literal, no closure. When the pool is full, the spawn is
dropped:
// pool full: silently drop (acceptable for particles)That comment is the interesting part. Dropping a particle is invisible. Dropping a frame is not. Once you accept that trade, sizing a pool becomes a design question rather than a safety question, and the sizes get written down with their reasoning:
// A death now layers ~80 particles across three emitters; ambient systems
// (seeker exhaust, laser embers, speed lines) tick a few more per frame.
// 256 slots keeps overlapping bursts from dropping visible particles.The same rule applies to the draw code. Every helper in render.ts is a worklet
that mutates a preallocated paint rather than building a new one:
function setCol(paint: SkPaint, color: SkColor, a: number): void {
"worklet";
paint.setColor(color);
paint.setAlphaf(a < 0 ? 0 : a > 1 ? 1 : a);
}Fonts, paints and cosmetics are all built once in a useMemo and passed into
render as arguments. Nothing in the hot path calls a constructor.
Talking to the JS thread without stalling
A game still needs the JS thread. Sound effects, haptics, saving a high score
and unlocking an achievement all live over there. The naive version fires a
runOnJS per event, and a busy frame can produce a dozen events.
Instead every event that fired this frame is packed into a bitmask, and there is one hop:
export function encodeFx(fx: FxQueue): number {
'worklet';
let m = 0;
if (fx.coin > 0) m |= BIT.coin;
if (fx.diamond > 0) m |= BIT.diamond;
if (fx.graze > 0) m |= BIT.graze;
if (fx.bomb > 0) m |= BIT.bomb;
if (fx.death > 0) m |= BIT.death;
// ... roughly thirty flags
return m;
}const fxMask = encodeFx(fx);
const fxMask2 = encodeFx2(fx);
if (fxMask !== 0 || fxMask2 !== 0)
runOnJS(drainFxJS)(fxMask, fxMask2, fx.persistBest, fx.bankGems, /* ... */);Note the guard. A frame where nothing happened does not cross the boundary at
all, which is most frames. On the JS side drainFxJS decodes the mask and fans
out to the same handlers the individual dispatches used to call.
Collapsing a counter into a single bit is a behaviour change, and it happened to be the behaviour that was already wanted. Two coins collected in one frame should play one coin sound, not two stacked on top of each other. The optimisation and the correct design agreed, which is a nice position to be in.
I wrote about this pattern on its own in one runOnJS per frame.
Tuning constants and variable refresh rates
Every feel constant in the game was tuned by hand at 60fps. Steering responsiveness, camera drag, trail decay, and a dozen more. They are all written as “move this fraction of the way there, each frame.”
That formulation breaks the moment the frame rate changes. A 0.2 lerp applied 120 times a second converges twice as fast as the same lerp at 60, so the controls get twitchy on a ProMotion iPhone and mushy on a device that drops to 30. The fix is one line of maths applied at every use site:
// The tuning constants are authored for 60fps per-frame steps; these convert a
// 60fps factor to the equivalent for the real dt, so steering/decay feel
// identical at 60Hz and 120Hz.
function fLerp(f: number, dt: number): number {
'worklet';
return 1 - Math.pow(1 - f, dt * 60);
}
function fDecay(f: number, dt: number): number {
'worklet';
return Math.pow(f, dt * 60);
}Some things should not be scaled by dt at all. The orb’s trail samples the
player position on a fixed cadence so the tail covers the same span of time no
matter the refresh rate:
const TRAIL_STEP = 1 / 60;On iOS you also have to ask for the frames. Without this, iOS caps you at 60 even on a 120Hz display:
"infoPlist": {
"CADisableMinimumFrameDurationOnPhone": true
}What it costs
This is not free, and it is worth being honest about the bill.
You lose React inside the frame path. There are no components for entities, no
props, no hooks. render.ts is two thousand lines of imperative canvas calls,
and it is a different job from writing an app.
Debugging gets harder. A crash inside a worklet gives you a worse stack trace
than a crash on the JS thread, and console.log from the UI thread arrives out
of order with everything else. Most of my debugging happens in a headless
harness that drives update() on plain Node with no Skia and no React Native
involved.
The rules are also viral. Every new feature has to be checked against the
no-allocation rule, and it is easy to break it by accident with a .map() or a
template string in a draw call. The plan documents in this repo all carry the
same acceptance line: no new per-frame allocations.
What you get back is that the platform stops being the constraint. The engine is plain TypeScript operating on a plain object, which means it also runs headless in a test script and in a browser, and both of those turned out to matter more than I expected.
If you want to see the result, Overbloom is on the App Store.