What Inspired It
Most mystery games script the moment you solve them. You pick the right dialogue option and a cutscene tells you that you were clever. We wanted a mystery where that moment is computed, where the game can check whether you actually proved something.
A phone was the obvious place to set it. People leave alibis in their texts without meaning to: "still at the studio", "left at ten", "on the ferry". In Read Receipts you get the victim's phone and nothing else.
It started as one of three ideas we stress-tested before writing any code. This one came back with a condition attached: build a real deduction engine, or don't build it.
How it plays
Every message that matters carries a typed claim saying who was where, and when: Nadia was at the studio from 21:40 to 22:20. You long-press a message to put its claim on the record, pin two claims on the evidence board, and run the check.
If both claims can't be true, the contradiction is confirmed and a locked thread opens. If they can, the game tells you why: different people, different times, or one place under two names. That explanation is worked out from the claims themselves, so a wrong guess still teaches you something. You can only accuse someone once every contradiction the case needs is proven, which stops anyone solving it by tapping every suspect in turn.
How we built it
It's an Expo SDK 57 app in React Native 0.86 and strict TypeScript, with expo-router for navigation and Zustand for state.
The rules live in src/engine/, which is plain TypeScript with no React Native imports. A test reads every file in the engine and the case content and fails the build if any of them imports a native module. That boundary is what lets the whole rule layer run under Node in milliseconds.
Cases are data, never logic. Each of the 16 scripts is validated with Zod when it loads, and a shared contract compares every pair of claims in a case. A case with $n$ claims needs
$$\binom{n}{2} = \frac{n(n-1)}{2}$$
comparisons, and if any pair contradicts without the author declaring it, the build fails.
The rest of the stack:
- RevenueCat, through its Test Store, for the in-app purchase. It runs the real purchase flow without a paid developer account.
- Supabase for optional accounts and syncing progress between devices, locked down with row-level security. The whole game plays as a guest.
- A Node script that synthesises all 26 sounds (9 cues and 17 music loops), so nothing in the app needs an audio licence.
- Vitest: 5,010 tests in 142 files, and 94.8% statement coverage on the engine and state layers.
The interface and all 16 cases are also translated into Spanish, French, German and Brazilian Portuguese.
Challenges
No Mac and no Android SDK. We built everything on a Windows laptop. Every iOS and Android binary was compiled on EAS Build, and we tested iOS on cloud simulators through Limrun.
Test Store keys only run in debug builds. A release build carrying one shows a "Wrong API Key" alert and quits. We wrote a small key policy module that keeps the key out of release builds, and split the builds into two profiles: one for playing, one for demonstrating a purchase.
Audio that passed review and failed on a phone. Our first music sat below 300 Hz, where a phone speaker produces almost nothing, so nobody heard it. Later, message tones played once per launch and then went silent, because expo-audio reports a finished sound as not loaded and our rewind never ran. Then stopping the music didn't stop it: remove() only unregisters a player, so every stopped loop kept playing under the next one. We found the last two by reading expo-audio's Kotlin and Swift source, and each now has a test that fails if it comes back.
Writing a machine can check. 795 messages, about 20,400 words, have to read like real people texting and still hold up under a rules engine. The player also has no name, gender or face anywhere in the text, and a test enforces that too.
What we learned
A passing test suite is not a playthrough. Every writing mistake we found came from someone reading a case for sense, and every audio bug came from someone listening on a real phone. What tests are good for is keeping a bug fixed once a person has found it. So we stopped writing rules in comments and started writing them as tests. The comments went stale. The tests didn't.
Built With
- android
- canva
- eas-build
- expo-audio
- expo-router
- expo.io
- github
- ios
- limrun
- mobbin
- node.js
- postgresql
- react-native-purchases
- react-native-reanimated
- revenuecat
- supabase
- typescript
- vitest
- zod
- zustand
Log in or sign up for Devpost to join the conversation.