Kotlin · Jetpack Compose · Room

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_parents

Rebuilt 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_words

Transient 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_runsmilestonesrecords

The 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

Settings

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

Restoring a backup

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.