One runOnJS per frame
A game frame can fire a dozen discrete events. Packing them into a bitmask turned a pile of thread hops into a single call, and fixed a sound bug on the way.
Overbloom runs its simulation in a UI-thread worklet. The JS thread does not participate in a frame, which is what makes 120fps achievable.
But a game still needs the JS thread. Sound effects, haptics, persisting a best score and checking achievement progress all live over there. So the interesting question is not how to avoid the boundary. It is how to cross it as few times as possible.
The version that does not scale
The obvious approach is to fire an effect where it happens. Collect a coin, call
runOnJS(playCoinSound)(). Graze an obstacle, call runOnJS(fireGrazeHaptic)().
This works and it reads well, and it falls apart on a busy frame. Late in a run
with a multiplier going, a single frame can produce a coin collect, a graze, a
combo increment, a power-up expiry warning, a proximity alert and a level up.
Each runOnJS is a scheduled call with its arguments serialised, so a frame that
should cost nothing on the boundary costs six crossings.
It also gets worse exactly when you can least afford it. Quiet frames are cheap. Frames where a lot is happening are the ones already under pressure from particles and draw calls, and those are precisely the frames generating the most events.
The queue
The simulation does not call effects at all now. It sets counters on a queue object that lives on the state:
fx.coin++;
fx.graze++;
fx.bhProx = 2;At the end of the frame, the loop encodes which of those fired into an integer:
// UI thread: encode which events fired this frame (0 = nothing, skip the 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.puGood > 0) m |= BIT.puGood;
if (fx.bomb > 0) m |= BIT.bomb;
if (fx.laser > 0) m |= BIT.laser;
if (fx.shieldBreak > 0) m |= BIT.shieldBreak;
if (fx.levelUp > 0) m |= BIT.levelUp;
if (fx.death > 0) m |= BIT.death;
// ...
return m;
}There are more than 32 event types, so there is a second mask. That is the only concession to JavaScript’s bitwise operators being 32-bit.
Not every event is a plain flag. The black hole proximity warning has tiers, and a tier fits in two bits:
// proximity tier travels as two bits (1 = near, 2 = close)
if (fx.bhProx === 1) m |= BIT.bhProx1;
else if (fx.bhProx >= 2) m |= BIT.bhProx2;Then one hop, guarded so that a frame with nothing to report does not cross at all:
const fx = g.fx;
const fxMask = encodeFx(fx);
const fxMask2 = encodeFx2(fx);
if (fxMask !== 0 || fxMask2 !== 0)
runOnJS(drainFxJS)(
fxMask,
fxMask2,
fx.persistBest,
fx.bankGems,
fx.coin,
fx.puKindLast,
g.comboMult,
// ...
);Values that do not fit in a bit ride along as ordinary arguments. The coin count is one of them, because the score needs the real number rather than the fact that at least one coin was collected.
On the other side, drainFxJS decodes the mask and calls the same handlers the
individual dispatches used to call. Every effect implementation was left alone.
The bug this fixed
Collapsing a counter into a single bit is a behaviour change. Two coins in one frame produce one bit, so the JS side plays one coin sound instead of two.
That is not a regression. It is the behaviour I wanted and had not written down. Two identical sound effects fired in the same frame do not sound like two events. They sound like one louder, phasier event, because they are separated by microseconds rather than by anything a person can perceive as rhythm. Every game with a coin sound solves this somehow, and I had been solving it by accident and inconsistently.
The comment at the top of the bridge records that this was deliberate:
// Counters collapse to "fired at least once", which matches the old behavior
// (each event played once per frame regardless of count).Cheaper and more correct at the same time is a rare combination, and it usually means the original code was expressing something it did not mean.
Where the pattern does not apply
There is a second hop in the frame loop, and it deliberately does not follow these rules. When a run actually ends, the loop sends a summary with lifetime stat deltas, including an array:
// A run truly ended this frame. ONE dedicated hop with the lifetime-stats
// summary; once per run, so the small argument serialization is fine (the
// no-allocation rule is per-frame).The no-allocation rule is about the frame path, not about the codebase. Something that happens once per run can allocate, serialise an array and take its time, because there is no next frame waiting on it. Applying a hot-path rule to cold paths is how performance work turns into unreadable code for no measurable gain.
Worth doing?
If you are firing a handful of runOnJS calls per second, no. This is a
pattern for a loop that runs at display refresh rate and produces discrete events
at unpredictable rates.
If that is your situation, the useful reframe is that the boundary is a channel with a cost per message rather than a cost per byte. Once you see it that way, batching is the obvious move and the encoding is just bookkeeping.
The game this came out of is on the App Store, and there is a fuller write-up of the architecture in a 120fps game in React Native.