Architecture
Android is three Gradle modules compiling to one APK, roughly 21,000 lines of Kotlin. iOS is a Swift package of three targets consumed by a thin app target, roughly 24,000 lines. Package com.wordtym, minSdk 31, targetSdk and compileSdk 36.
Three Modules on Android
All three compile into one APK. These are ordinary libraries, not dynamic features.
:core-puzzle
Generation and progression maths
- Declared as a plain Kotlin/JVM library with no Android dependency at all
- That is enforced by its build.gradle.kts, not by discipline
- Buys JVM-speed property tests with no emulator
- Holds all the algorithmic risk, and runs thousands of iterations
:core-data
Room, repositories, backup
- Room + KSP over one SQLite file, wordtym.db, WAL journal
- 14 tables at schema version 4, with a real migration per bump
- Corpus import, backup export and restore
- DataStore for settings, deliberately outside SQLite
:app
Compose UI
- Jetpack Compose across 7 screens and a NavGraph
- No DI framework — one typed factory builds all six view models
- Material 3 with dynamic colour declined, theme art and 82 trophy bitmaps
- Edge-to-edge and a real predictive-back handler
The no-Android constraint on :core-puzzle is the point of the split. The generator carries all the algorithmic risk in the project — placement, the bag draw, the accidental-duplicate scan, density targets — and testing it properly means running thousands of seeds. On the JVM that takes seconds; through an emulator it would be slow enough that nobody would run it. A readable, dependency-free core turned out to be the thing that made the iOS port tractable — it was the blueprint to port rather than an abstraction to reuse. That was always described as a side effect of good structure rather than a requirement being served, and it stayed that way: no design compromise was made for a port that did not exist yet, and the port still came out cheaply when it was wanted.
Three Targets on iOS
The same split, in Swift Package Manager terms. All the code lives in the package; App/ is a thin app target that consumes it. Dependencies point one way only — WordtymApp → WordtymData → WordtymPuzzle.
WordtymPuzzle
Generation and progression
- No dependencies, so its 78 tests run at full speed
- Grid layout, placement, filler sampling, the word bag
- Scoring, streaks, milestones and records
- The mirror of :core-puzzle, and where parity is asserted
WordtymData
GRDB, repositories, backup
- GRDB over SQLite with plain SQL
- The same 14 tables at schema version 4
- 122 tests over import, repositories, settings and backup
- UserDefaults for settings, same keys as DataStore
WordtymApp
SwiftUI views
- Seven screens, matching Android one for one
- The grid canvas and the trophy artwork
- 120 tests driving the observable view models behind them
- NavigationStack, which brings the interactive back-swipe
One third-party dependency
GRDB.swift, chosen because it gives SQLite access with plain SQL — which is what keeps the statistics queries character-for-character the same as Room's on the other side. A higher-level ORM would have meant two different query languages producing two sets of numbers to reconcile.
Platform specifics differ where they should
Haptics go through UIImpactFeedbackGenerator rather than a raw waveform, navigation through NavigationStack, storage through GRDB, settings through UserDefaults. The game, the rules and the generated puzzles are identical.
A puzzle is its seed
Generation is deterministic, so a puzzle is about 50 bytes: a seed, a category, a difficulty, and a generator version. Completed puzzles store statistics only — nobody replays a finished word search.
The active puzzle also stores its grid as a plain string, so an app update can never mutate a game in progress. If the generator changes shape, the in-flight board is still exactly the board the player was looking at.
PCG-XSH-RR, by hand
32-bit, implemented directly rather than using kotlin.random.Random.
Platform RNG implementations can change between versions. Since a stored puzzle is its seed, that would silently change every puzzle in the database — an in-progress game would regenerate into a different board on the next launch. Owning the generator removes the dependency entirely.
Parity Is a Test, Not a Promise
Two native codebases can drift the moment nobody is looking. What stops that here is a set of golden vectors exported from the Kotlin generator and asserted by the Swift one, so “the same seed gives the same puzzle” is something the suite proves on every run rather than something the architecture merely intends.
RNG
The raw generator stream. Isolates arithmetic — wrapping multiplies, the rotate, rejection sampling.
Bag
Deck shuffle, the recency window, and the reshuffle boundary where a deck runs out mid-draw.
Generation
225 whole puzzles — every theme at every size — compared as literal grid strings.
Progression
Scoring, streaks, milestones and records against fixed inputs.
The layers are the diagnosis
They are separated so a failure names its own cause. If the RNG layer passes and generation fails, the bug is ordering or sorting — never arithmetic. That turns a wrong grid, which is an impossible thing to eyeball across 225 boards, into a pointer at one function.
Where two languages quietly disagree
Kotlin sorts stably and Swift does not. Kotlin's integers wrap where Swift's trap. Kotlin's Set iterates in insertion order and Swift's is unordered and seeded per process. Each of those changes the board without changing the intent, and none of them is visible in a code review — which is exactly why the contract is a fixture file rather than a convention.
14 Tables, Three Lifetimes
One SQLite file, wordtym.db, at schema version 4 with a WAL journal. The grouping is by how recoverable each group is, which is what decides what a backup carries.
Corpus
Imported, disposable
categorieswordscategory_wordscategory_parentsRebuilt from a single bundled corpus.json — merged at build time from the 42 theme files in data/words/ and stamped with a hash of them — and re-imported whenever the stored version no longer matches that hash and the importer version. Excluded from backups entirely, since it is reproducible from the app's own assets and carrying it would multiply backup size for nothing.
Play
One puzzle at a time
puzzlespuzzle_wordsbagsrecent_wordsTransient by design. Exactly one puzzle is ACTIVE; starting another archives the previous one to COMPLETE. recent_words is a short rolling window whose only job is to stop a word reappearing too soon, so losing it costs nothing and it is left out of backups.
Progression
Cannot be re-earned
discoveredstreaksstreak_runsmilestonesrecordsThe part worth protecting. discovered is append-only and can only be re-earned by replaying months of puzzles. It is guarded by its primary key, so duplicates are impossible at the database level rather than by application discipline.
Nothing derived is ever stored
Not level, not meter fill, not theme mastery rank, not longest-ever streak. A stored copy can disagree with the collection after a restore or a missed write; a derived one cannot. All of it falls out of one indexed COUNT(*). This is a schema rule, not a style preference.
words is keyed by unique gridForm
Not by category entry. That is what makes the collection count unique words rather than memberships — 11,281 rather than 14,592 — and it is why a word appearing in three themes is discovered once. Membership lives in category_words.
Backup, Format Version 1
Everything in Play and Progression, plus settings. Nothing from Corpus.
discovered is translated, not copied
It is the one place a value is transformed on the way out, and the reason is worth stating: autoincrement ids are not stable across installs. A re-imported corpus assigns its own.
Exporting the collection by id would attach it to whatever words happened to land on those numbers. So it is exported as grid forms and looked back up on restore — which is also why the corpus import has to run before the restore, not after.
The round-trip test asserts the restored contents explicitly rather than merely that the import returned successfully. A lost word collection cannot be re-earned, only re-played over months.
A restore has a safety net
A restore is the only irreversible action in the app. So the importer writes the current state to filesDir/restore-safety/ before opening the transaction, and returns that file so Settings can offer an undo.
Only the most recent snapshot is kept: an undo is for the restore that just happened, and a drawer of stale ones would be its own confusion.
The confirmation dialog stays — but a warning was never the same thing as a way back.

Settings
Hint allowance, timer visibility, and backup. Nothing here changes difficulty — the three presets are fixed.

Restoring a backup
The only irreversible action in the app, so the current state is written to a safety snapshot before the transaction opens and can be undone afterwards.
Targeting API 36
minSdk stays 31 — it is only the install floor, nothing in APIs 32–36 is needed, and the lower floor keeps a phone usable for testing. But targetSdk 36 makes three platform behaviours mandatory, and each touches the layout directly.
Edge-to-edge cannot be opted out of
The grid is a centered square sized to min(width, height), so an unhandled inset silently clips it or knocks it off-centre.
Resizability restrictions are ignored on large screens
The app must survive split-screen at arbitrary widths. This collides with width-based difficulty gating, and the resolution is that gating governs starting a puzzle, never continuing one. A Hard puzzle in progress keeps playing at reduced cell size down to the 32dp floor, then scrolls. A resize must never make a puzzle unplayable.
Predictive back is on by default
So the puzzle screen needs a real back handler, or the gesture animation shows a blank frame.
Built to Be Updated, Not Published
Sideloaded rather than distributed, but engineered as though it were shipping: release signing, R8, crash capture, auto-backup with manual export, an adaptive icon, and strings in resources. No Play listing, no translations, and no third-party analytics.
Install identity is permanent
The signing key and the applicationId are both fixed from the first install. Change either and Android treats the result as a different app: no in-place update, and the play history does not survive. For an app whose whole progression layer is a collection that can only be re-earned by replaying months of puzzles, that makes both of them load-bearing.