Architecture
One APK, three Gradle modules, 107 Kotlin files and roughly 12,155 lines. Package com.wordtym, minSdk 31, targetSdk and compileSdk 36.
Three Modules
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 3
- Corpus import, backup export and restore
- DataStore for settings, deliberately outside SQLite
:app
Compose UI
- Jetpack Compose across 7 screens and a NavGraph
- Hilt DI, Material 3 with dynamic colour declined
- Theme photographs 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 is also what would make an iOS port possible later, but that is a side effect of good structure rather than a requirement being served — src/wordtym-ios/ is an empty placeholder and no design compromise is made for it.
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.
14 Tables, Three Lifetimes
One SQLite file, wordtym.db, at schema version 3 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 assets/words/*.json and re-imported whenever the stored version no longer matches the asset hash and importer version. Excluded from backups entirely — it is reproducible from the APK'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.