Inspiration
Most "relaxing" puzzle games aren't. You open one and something immediately starts counting at you — a timer, five lives, a streak about to break, an interstitial after every board. The whole genre borrowed its vocabulary from slot machines, then called itself casual.
I wanted to know what was actually left of a colour-sort puzzle once you removed every one of those pressures. No timer. No lives. No fail state. No move limit. Unlimited undo, so a wrong pour costs a tap and nothing else. Strip all of it away and what remains is the shape of the puzzle itself — and the question of whether solving something quietly, with your hands, is enough to keep someone coming back.
Zen Sort is that experiment, shipped to both stores.
What it does
You pour coloured blocks between glass vessels until each vessel holds a single colour. That's the whole rule.
- 3,000 levels, every one machine-verified solvable, on boards up to 14 vessels wide
- 48 animal characters across 4 visual themes — the blocks have faces, so colour-blind players can sort by character instead of hue
- No timers, no lives, no fail state, and unlimited undo
- A daily puzzle with a streak that is genuinely optional
- A collectible Shore that fills in as you play
- A wordless tutorial — an animated hand taps the vessel it wants, so the game teaches itself regardless of what language you read
How I built it
React Native 0.86 on the New Architecture (Fabric + TurboModules), React 19, Hermes, TypeScript in strict mode, shipping from one codebase to iOS and Android.
- Engine: a pure TypeScript core with no React in it at all — board state, legal pours, and the level generator. Keeping it pure is what makes 3,000 levels testable.
- Animation: Reanimated 4 with worklets, running the pour arcs, vessel breathing, and celebration entirely on the UI thread.
- Rendering:
react-native-svgfor the gradients and icon set,react-native-linear-gradientfor the glass. - State & storage: Zustand, persisted through MMKV.
- Services: RevenueCat for purchases on both stores, Firebase Analytics + Crashlytics, Google Mobile Ads, Notifee for the daily reminder, and
react-native-audio-apifor the ambient layer. - Tests: 3,139 tests across 21 suites. The important one replays a stored solution through all 3,000 levels and asserts each finishes solved — so an unsolvable level cannot reach a player.
Challenges I ran into
The crash that only happened on real hardware. The app ran perfectly in the simulator and died before the first frame on a physical iPhone — EXC_BAD_ACCESS inside Props::Props(). The cause was a one-line build hook I had added myself. RN 0.86 ships React Core as a prebuilt xcframework in two variants, and a script phase picks between them by grepping for DEBUG=1. My hook defined NDEBUG=1 on the pods, which quietly stripped REACT_NATIVE_DEBUG from their copy of the shared headers. Both halves still linked — but the pods were now building a Props struct smaller than the one the framework writes into. Every third-party Fabric component corrupted the heap as it registered. A link error would have been the kinder outcome.
An ANR I could only see in thread dumps. Testers reported freezes I could not reproduce. Five dumps from real devices — including a 32-bit ARMv7 phone — all shared one frame: react-native-svg dispatching a Fabric layout event from inside onDraw, which Reanimated then handled synchronously, mutating views mid-draw and dirtying the tree again. A re-entrant draw loop. Reading the library source showed every vessel was permanently mounting six animated SVG roots that stayed mounted at opacity 0 — 84 of them on a full board, each firing that chain every frame. Mounting the celebration only while it plays took the resting cost to zero.
Two SDKs fighting over crash reporting. Crashlytics recorded nothing on iOS for weeks. Two separate causes: Firebase was never initialised (Android auto-configures through a Gradle plugin; iOS has no equivalent), and once that was fixed, the Ads SDK was installing its own signal handlers on top of Crashlytics' and swallowing every native report. An empty crash dashboard reads exactly like a healthy app, which is what made it expensive.
Accomplishments that I'm proud of
Every level is provably solvable — not spot-checked, replayed in CI. Shipping a puzzle game where "level 2,847 is impossible" simply cannot happen is worth more than any feature on the list.
And the restraint held. There was pressure at every step to add a timer, a lives meter, a "you lost!" screen — the things that reliably lift retention charts. The game ships without them.
What I learned
Measure before you believe. Nearly every bug above was something I was confident about and wrong about — including one where the root cause turned out to be my own earlier "fix." The thread dump, the crash report, and nm on the binary were right every time my intuition wasn't.
Also: the simulator lies in both directions. It crashes on things hardware handles, and happily passes things hardware rejects.
What's next for Zen Sort
An automatic low-end device tier — reading the device's memory class at launch and reducing concurrent animations on weaker hardware, rather than waiting for the user to find an accessibility toggle. Those ANR dumps proved real players are on 32-bit phones, and they deserve the calm version too.
Built With
- admob
- cocoapods
- crashlytics
- firebase
- firebase-analytics
- gesture-handler
- gradle
- hermes
- ios
- jest
- kotlin
- mmkv
- nitro-modules
- notifee
- react
- react-native
- react-native-svg
- react-navigation
- reanimated
- revenuecat
- swift
- typescript
- zustand
Log in or sign up for Devpost to join the conversation.