Building a crossword engine in React Native
Exact-hit-test tappable cells, pinch-zoom with breathing room, an interlocking offline level generator, and the small details that make a puzzle feel right.
A crossword looks simple: a grid of letters. The moment you build one that has to feel good under a thumb, on a small screen, with a keyboard popping in and out, it stops being simple.
Taps that never miss
The first version computed which cell you tapped from the touch coordinates: map the x and y into grid space, divide by cell size, done. It drifted. Once the board could zoom and pan, the math and the rendered cell stopped agreeing, and taps landed on the wrong square.
The fix was to stop doing coordinate math entirely. Every cell is its own touchable component. The operating system hit-tests the actual rendered cell, at whatever zoom or pan offset it currently has. There is no arithmetic that can drift, because there is no arithmetic.
<Pressable onPress={() => onCellPress(row, col)} style={[styles.cell, { width: cell, height: cell }]}>
...
</Pressable>Tap-the-same-cell-to-flip-direction (across becomes down) is then trivial: track the last tapped cell, and if it is the same crossing cell, switch direction.
Zoom that breathes
The board fits the viewport at 1x and pinch-zooms up to 3x with a clamped pan so it never drifts off screen. Testers immediately found the flaw: at 1x it looked great, but zoomed in, the outer cells sat flush against the screen edge with no margin.
The naive fix (pad the viewport) does not survive zoom. The real fix is to bake the breathing room into the board content as a one-cell gutter, and include it in the pan bounds. Now the margin is part of the thing being scaled, so it is always there, at any zoom level. A small idea, but it is the difference between "cramped" and "comfortable".
The keyboard problem
On a phone, the keyboard covers half the screen. The grid lives above the clue and the input. When a player solves the final clue and the success modal appears, the keyboard was still up and covering it. The fix is one line at the moment of completion: blur the input and dismiss the keyboard before showing the modal. Obvious in hindsight, invisible until a tester complains.
Another keyboard lesson: do not constrain the answer field with maxLength. It
seems helpful (the answer is six letters, so cap at six) but on some Android
keyboards, typing past the cap causes character substitution mid-word. Remove
the cap, let people type freely, and simply do not match a too-long guess. Free
typing always beats clever constraints.
Generating 100 interlocking levels
Each app needs 100 crosswords on its theme. Hand-authoring that is a non-starter. The generator takes a themed word-and-clue list and places answers into a grid, interlocking them on shared letters, using a seeded pseudo-random number generator so the output is deterministic. Deterministic matters: regenerating the level set must never reshuffle levels a player has already solved.
A few generated levels drop a word or two that had no valid crossing. That is fine; the level is still a complete, solvable puzzle. Perfect packing was not worth the complexity.
Everything ships in the bundle. 100 level JSON files per app, imported statically, no network. The whole game works on a plane.
The details that add up
- A floating "?" tooltip teaches the gestures on the first few plays, then gets out of the way.
- Hint reveals are highlighted in their own color, distinct from solved cells.
- A subtle haptic on solve, an error shake on a wrong guess.
- Auto-advance to the next unsolved clue after a correct answer.
None of these are hard. All of them are the difference between a tech demo and something people actually want to play.
Next: the hint economy, where game design and monetization meet.