Building Stack the World, a crane-stacking game in React Native and Skia
I built Stack the World with Expo, React Native and React Native Skia, and there is no physics engine in it. The tower is a small simulation written in plain TypeScript, and Skia only draws it. As I write this, the Android build is in Google Play closed testing and the iOS build has been sitting in App Store review since October 1.
People ask whether React Native is a reasonable choice for a game. For a 2D, one-tap game with a controlled scene, it has been fine for me.
What the game is
A crane swings a floor back and forth at the top of the screen. You tap, the floor drops onto your tower. Land it dead center and it counts as perfect, which builds a combo. Land it a little off and it stays, but the tower's center of mass shifts. Miss, and the floor tips over and you lose a life.
Version 1.0 covers Türkiye, Germany and France. Each country has three cities, each city has ten buildings, and the last building in every city is its landmark, so in Paris you end up stacking the Eiffel Tower floor by floor. There is also an endless mode for chasing a height record.
Why there's no physics engine
One of the first lines in the design doc was "no physics engine, no matter-js." A rigid body engine will compute everything correctly, and it will also decide for you how much the tower wobbles and when, which is the one thing I wanted to tune by hand.
So the tower lives in src/game/sim/ as pure TypeScript that knows nothing about React or Skia. Each function starts with the 'worklet' directive, which lets the same code run in Jest and on Reanimated's UI thread. I can test the core of the game without a device.
The loop advances in fixed 60 Hz steps with an accumulator. A dropped frame doesn't slow the simulation, and a 120 Hz screen doesn't speed it up. The hook is a plain pendulum and the tower's lean is a spring-damper:
// Simplified sketch of the real loop
export function advance(s: SimState, dtMs: number) {
'worklet';
s.acc += dtMs;
while (s.acc >= STEP_MS) {
s.t += STEP_MS / 1000;
s.hookAngle = s.amp * Math.sin(s.omega * s.t);
stepSway(s, STEP_MS / 1000);
s.acc -= STEP_MS;
}
}
Amplitude and speed both grow as the tower gets taller, and that is most of the difficulty curve. State sits in a Reanimated shared value and Skia draws from it.
React only renders when an event happens.
Drawn width has to equal physics width
The first Eiffel level looked broken. The wrong piece hung from the hook, the base didn't sit on the plate, and platforms poked out to the side. The cause was that pieces were drawn inside different floor boxes, so the width on screen had drifted away from the width the physics used.
The rule since then is a single line in my notes: drawn width equals physics width. The Eiffel base is drawn across two floors, while physics still treats each floor as one block.
Forgiveness is a number
I went back and forth on how far off-center a floor can land and still stay. Fifty percent was close to physically right but felt harsh, the old default of thirty felt too lenient, and I settled on forty. A separate bug showed up later: dropping the first floor toward the right edge made the balance meter fall on the very first move. Now each floor is measured only against the one below it, and the first floor is the reference.
Fixing performance by measuring it
I profiled on a Redmi Note 12 Pro+ with perfetto, gfxinfo and meminfo. Gameplay ran at roughly 119 fps, with frame times of 8.2 ms at p50 and 28.5 ms at p99. Skia drawing took about 6.2 ms per frame on average, around three quarters of a 120 Hz budget.
The real problem was first visits to each screen. Android clears an app's shader cache every time the app updates, so after an update every Skia effect compiled its GPU program on the UI thread the first time it was drawn, and the user saw a freeze. I measured 441 ms on the map, 298 ms in the shop and 230 ms entering a game.
The fix was to warm shaders while the splash screen is still up. A small surface from Skia.Surface.MakeOffscreen gets one draw of each effect the game uses (gradients, blurs, color matrices, scene paints) and is then released. RN Skia keeps one GPU context per thread, so the compiled programs carry over to the on-screen canvases. Each step has its own try/catch, a failure never blocks startup, and the whole thing has a time cap.
After warm-up, none of those screens had a frame over 60 ms.
Two experiments went the other way. Swapping icons for PNGs shown with a regular <Image> made things worse, producing 25 to 87 ms frames on every visit. Capping Skia's texture cache at 96 MB cut memory, but it needed a native patch and I decided it wasn't worth carrying.
The other leak was background work. With the app in the background, the mascot and menu button animations were still firing about 77 frame callbacks per second. Adding an AppState check to the shared loop hook stopped all of them in one change, and background CPU on the emulator dropped by about 90 percent. All of these numbers come from a single device.
Eleven languages, and the font problem
The game runs in English, Turkish, German, French, Spanish, Portuguese, Russian, Japanese, Korean, Simplified Chinese and Indonesian. The language follows the phone's setting, can be switched in-game, and adding a new one means dropping a single JSON file into the locales folder.
Fonts took more work than translation. Full Japanese, Korean and Chinese fonts are large, so a script collects every character used in each language's JSON and builds a subset font, aiming for 40 to 120 KB per language. The catch is that if text changes and nobody regenerates the font, new characters show up as empty boxes. That happened when I renamed some wind-system terms and had to rebuild the ja and zh fonts, so the tests now check that every CJK character in the strings exists in its font.
No player-facing text is baked into images. Single-line labels shrink to at most 70 percent with adjustsFontSizeToFit if they don't fit, and I check German, Russian and Japanese on every screen at 360 px width.
The store process, as it actually went
A new personal Google Play developer account has to run a closed test before it can publish: at least 12 testers, opted in for 14 days straight. What I didn't know at first is that being on the tester list doesn't count. Only people who click the opt-in link do, so I rewrote the tester call to ask for three things in order: join the group, opt in, install the app. Once the requirements were met I applied for production access on October 19 and I'm waiting on the result.
Closed testing also surfaced a nasty bug. Google sign-in failed for everyone who installed from Play, with a generic "couldn't connect" message. Logcat told the real story: the app wasn't registered for OAuth. Play re-signs your app with its own key, so the SHA-1 of your upload key isn't enough, and you need to register the app-signing SHA-1 from Play Console as an Android client in Google Cloud. Once I added it, sign-in worked immediately.
iOS has been quieter. The build has shown "Waiting for Review" since October 1 with no rejection and no questions. One likely cause was an updated developer agreement that the account holder hadn't accepted yet; it has been accepted and I'm following up. I'm holding off on resubmitting, because that can cost the place in the queue. While a version waits for review you also can't change its screenshots, so the new store images are ready and waiting.
Where it stands
The 1.0 scope is done. There are more than 7,000 Jest tests, and nothing reaches the main branch until tsc is clean and the full suite passes. Next up is profiling on a weaker Android phone and getting the App Store answer.
If you're thinking about a game in React Native, start by separating the simulation from the screen. A tower you can test without a phone makes both tuning and debugging far easier.