Project case study
Dalalat
Published Oct 15, 2026
Implementation notes
What it is
Dalalat (دلالات) is an Arabic crossword game for phones.
Each level is a small grid of about eight words. You slide a finger across a word, or tap one of its cells, and the clue opens. The clue says what kind of clue it is (a definition, a riddle, a saying with a blank, a bit of trivia, or emojis) and how many letters the answer has. You build the answer from letter tiles: the letters you need plus a few decoys. Letters you solve show up in the words that cross them.
Two hints cost credits: remove a wrong letter (1 credit) or reveal a letter (3 credits). Solve 75% of the grid and the next level opens. Stars go up with how much you solved, and all three need the whole grid.
Scope
I built the whole thing:
- the product and UI design, and the four-tile brand mark;
- the app, in Expo and React Native;
- the backend in Convex, with the credits, referrals, ad rewards, progress sync, and level publishing;
- the pipeline that generates, checks, and reviews the levels;
- the website, support page, and privacy policy that the stores and AdMob need.
Like most of what I build now, I wrote a lot of it with Claude Code. Claude is also inside the product: it writes the clues and plays several reviewing roles in the level pipeline.
The app
The stack is Expo 57, React Native 0.86, React 19.2, and TypeScript in strict mode, with Expo Router for screens, NativeWind for styling, and Reanimated 4 for the grid and tiles.
Arabic all the way down
An Arabic crossword raises questions an English one doesn’t:
- The grid runs right to left. The touch handlers flip the columns, so a finger on the left edge of the screen lands on the grid’s last column. Sliding across cells to pick a word has to follow the same flip.
- Letters are compared exactly. A cell holds one letter, so أ and ا, or ة and ه, are different letters, and two words only cross on identical ones. Vowel marks are stripped before any comparison or length check.
- Numbers are Eastern Arabic (٠ to ٩) everywhere in the interface.
- Some characters break in right-to-left text.
<and>flip direction inside a sentence, so clues aren’t allowed to contain them.
Design
The look is warm and quiet: a cream background, forest green and honey tiles, and the Tajawal typeface. It’s light only.
The pieces you touch, grid cells and letter tiles, have a solid base they sink into when pressed, so they feel physical. Everything else, buttons included, is flat. Haptics play on iOS, and the sound effects are off by default.
State and sync
Game state is React Context with reducers: one context for level progress, one for the level list, one for credits. Progress is saved on the device and synced to Convex. When the two disagree, each level keeps its better result.
Levels without app updates
Levels ship separately from the app.
The app bundles a set of levels, keeps a cached copy of downloaded ones, and checks Convex for new ones on launch and whenever it comes back to the foreground. Every level has a revision number, and the app keeps whichever copy, bundled or downloaded, has the higher one.
A few rules keep this safe across app versions:
- Saved progress resets only when a level’s answers or grid change. A reworded clue doesn’t cost anyone their progress.
- A level that needs features older builds don’t have gets a higher format number, and older builds skip it.
- A removed level stays in the database, marked as removed at the next revision, and apps drop their copy.
JavaScript fixes go out over the air with EAS Update, to builds with the same native version. Anything that touches native code or config bumps the version.
Credits without accounts
There’s no sign-up. Each install gets an anonymous device ID, also kept in the iOS Keychain so it survives a reinstall.
That makes credits the part to be careful with. Every public backend function has to trust the ID it’s given, and anyone can call them. Before launch, the backend still had public mutations that awarded ad credits and spent credits directly. Anyone who called them could give themselves credits.
Now credits come from three places only: a one-time starting grant, a referral (once per device), and ads. And a real ad pays only through AdMob’s server-side verification:
- The player watches a rewarded ad.
- Google calls an HTTP endpoint on Convex with the transaction.
- A Node action checks Google’s signature against Google’s published keys.
- The reward is written once per transaction, so a replayed callback pays nothing.
The app never tells the backend it watched an ad. Google does. I tested it end to end on a real phone before calling it done.
Before the first ad, the app shows Google’s consent form where the law requires it, then the iOS tracking prompt. Settings offers the consent form again where it applies, and links the privacy policy.
The level pipeline
The game is only as good as its clues. A clue that fits three answers, or a word nobody uses, makes the player feel cheated, and no animation fixes that. That’s where most of the work went.
The first version asked a model for words and clues and placed them in a grid. It produced levels that looked right and weren’t good, because nothing checked meaning. So I rebuilt it around checking:
- A vetted word pool. Words come from an Arabic frequency list, not from the model. A filter and a classification model narrow 8,833 rows down to 4,769 words, each with a difficulty tier.
- A grid search. A beam search tries thousands of layouts per level and scores them as graphs: loops, dead ends, single points of failure. Words sharing a root never share a level.
- Several clues per answer. Claude writes 3 to 5 candidates per answer, at different difficulty levels.
- A blind solver and a judge. A solver sees only the clue and the length, and guesses. A judge rates each clue, and hard gates in code reject the failures. 240 of 2,014 clues failed at least one gate.
- A simulated player. Clues are chosen per grid, then 300 simulated players solve the level to check it opens easily, doesn’t get stuck, and lands on its difficulty target.
- A reviewer plays the level. It sees what a player sees and approves it or not. Any change afterwards makes the review stale.
- Validation and publishing. A level that isn’t approved, has a blocker, or misses its target can’t be written.
All 49 current levels went through it. I wrote up the whole pipeline, with real examples of rejected clues, in The AI Writes the Clues. Everything Else Is Checking.
What I learned
- In a content game, the content is the product. The app took less time than the levels, and the levels mostly took checking, not generating.
- Arabic details only show up when you hit them. Missing hamza seats in a word list, characters that flip in right-to-left text, letters that look the same but don’t match. None of it was on a plan.
- Without accounts, assume every request lies. If something is worth money, it has to come from a source the server can verify, not from the app saying so.
- Repeated feedback should become code. Whenever a reviewer, human or model, raised the same problem twice, I turned it into a rule, so it wouldn’t come up a third time.