Vocabloot is a vocabulary app: you snap the real things around you and learn their names in the language you're learning. Open the app, point the camera at an apple, and you get "der Apfel" (the apple) with a sticker, two example sentences and the sound of it. That works for everything you can point a camera at. It does nothing for "Freut mich" (nice to meet you), "Wie geht es dir?" (how are you?) or "Auf Wiedersehen" (goodbye). Greetings, set phrases, anything you can't photograph: the words you need first, and the words the camera can't see.
Here's one snap from the Wordathon, the 100 words I'm looting in public on X:
So we built a second way in: decks, the same idea as a shared Anki or Quizlet deck. A deck is a .vlbackup file the app opens as its own, and every word in it gets what a camera word gets: a sticker, two sentences, tap-a-word meanings, the sound. That takes the camera off the ceiling. A deck can be anything: a whole level like A1, then A2; one topic; or just the greetings you need on day one, in any of the ten languages the app teaches. At vocabloot.com/decks you can download decks we curate, with more decks in progress. If you want to make your own, the Deck Kit does it as a ChatGPT skill or a Claude Code plugin. Then contact us and we'll put it up for everyone.
Building that, I ran into a problem I hadn't planned for. The deck page on the website, and the Deck Kit's packer, the small program that turns a maker's folder of cards into a .vlbackup deck file, both have to make decisions the app already makes: which word you tapped, which word lights up while a sentence is spoken, whether a card is valid at all. Those rules lived in the app's Kotlin. The website is JavaScript.
Instead of rewriting them, the app's rules compile to JavaScript too, and the website uses the same Kotlin the apps use. That's the thing I want to show: Kotlin on three platforms, Android, iPhone and the web. Across the whole app, 85% of the Kotlin is shared, 79,039 of 93,100 lines.
Here's what it looks like. Open any word and you get two example sentences. Press play and the word being spoken lights up as it's said. Tap any word in the sentence and a small card slides up with what it means. That works on Android and iOS, and on our community deck web page.
Tap a word and the same card comes up on all three, from the same code deciding which word you meant:
The three small decisions
- Which word did you tap? Take the sentence "Hallo, ich heiße Anna." A tap lands on one character, and something has to decide that it belongs to "Hallo" and that the comma right after it belongs to no word at all.
- Which word should light up right now? Speech engines report the words they say, not the words you wrote. A hyphenated word like "well-known" comes back as two, and the written word still has to light up as one.
- Is this card valid? Is "numeral" a real part of speech in our system? What about "number"? Does every word in the sentence have a meaning attached?
Each answer is a few dozen lines, easy to get slightly wrong, and if Android, iPhone and the website each had their own copy, they would drift: the app fixes the hyphen case, the website doesn't, and a month later the site lights up words differently from the phone in your pocket.
So before I wrote the web version I wrote myself one rule: the web must not reinvent. If the app already has the answer, the website uses it.
The shared code, and how it reaches the browser
Two small modules hold the decisions. One works out which word you tapped and which word is being spoken right now: 298 lines. The other reads and writes .vlbackup deck files and knows which parts of speech exist: 647 lines. Those 945 lines compile for Android, iPhone and the browser, and they are tested once: 94 tests, run on the JVM and on Node from the same source.
The web's own code is just the screen. The logic and the content come through the shared Kotlin module, the same as in the apps.
Kotlin normally compiles to code for Android and for iPhone. It can also compile to JavaScript, the language a browser runs. Turning that on for a module is one block in its build file:
// shared/format/build.gradle.kts
js {
browser()
nodejs()
binaries.library()
}
browser() is for the deck page. nodejs() is for Node, which is JavaScript running outside a browser, on a computer or a server. It also produces a plain JavaScript library that the script building our website can load like any other; that script calls PartOfSpeech.group(...) to sort cards into their chips. There is no JavaScript copy of that list anywhere:
// decks/render/shared.mjs, the website's build script: plain Node, no framework
const formatLib = createRequire(import.meta.url)("../lib/SnapLingo-shared-format.js");
export const PartOfSpeech = formatLib.com.tntstudios.snaplingo.format.PartOfSpeech;
export function posGroup(partOfSpeech) {
return PartOfSpeech.group(partOfSpeech ?? null); // the app's rule, not a copy of it
}
The whole thing is an 83 KB bundle, gzipped. It's Kotlin/JS rather than Kotlin/Wasm because only the logic is shared and the page is drawn in plain HTML, which is what JetBrains recommends JS for. Kotlin 2.3.20, Compose Multiplatform 1.10.3.
What a .vlbackup deck file is
The rest of this post needs one piece of background.
A deck is a .vlbackup file, the same format the app uses when you back up your own wordbook. On a Mac it's just a Document. On an iPhone, Files shows it with the Vocabloot icon, because the app told iOS it owns that kind of file, and sharing it to Vocabloot imports it.
Inside are the deck's words, a picture for every sticker, and a small deck.json that says what the deck is. Every entry carries a fingerprint: a short number worked out from its bytes, which changes if a single bit changes. When the app opens the file it works out every fingerprint again and refuses the file if one doesn't match. A broken download can never put half a word in your wordbook. It's the same idea as the signature on an Android app, just lighter: it proves the file arrived whole, not who made it. That's why we check every deck before it's published on vocabloot.com/decks.
Every word has an id. A deck card's id is worked out from its text and the deck's name, so the same card always gets the same id, on any machine. That's why opening a deck twice adds nothing: the app already has those ids and skips them.
From a folder to a .vlbackup deck file
The Deck Kit writes the cards, their sentences and their stickers into a folder. The packer, a small program that runs on Node, reads that folder, checks it and writes the .vlbackup deck file. It checks the deck with the same Kotlin code the app uses to check it, so if the packer says a deck is fine, the app will say so too. A bad sentence or an unknown part of speech gets refused before anyone downloads it.
Three things that weren't free
I had to write SHA-256 by hand. SHA-256 is the function that makes the fingerprints from the section above: feed it any bytes and it gives back a fixed 32-byte number, and the same bytes always give the same number. The packer runs it when it writes a deck; the app runs it again when it opens one, and the two numbers have to match. Plain Kotlin has no such function built in, so each platform supplies its own: Android and the JVM use MessageDigest, iOS uses CommonCrypto. The browser has one too, but it answers later, as a promise, instead of returning the number right away, and changing the shared code to wait would have touched every place in the apps that uses it. So the browser got the algorithm written out in plain Kotlin.
// shared/format: one declaration, one answer per platform
internal expect fun sha256(bytes: ByteArray): ByteArray
// androidMain and jvmMain
internal actual fun sha256(bytes: ByteArray): ByteArray =
MessageDigest.getInstance("SHA-256").digest(bytes)
// iosMain: CommonCrypto's CC_SHA256, twelve lines of pinning bytes
// jsMain: Sha256 is the algorithm itself, 62 lines of plain Kotlin
internal actual fun sha256(bytes: ByteArray): ByteArray = Sha256.digest(bytes)
Kotlin lists don't cross into JavaScript. The parts-of-speech list is a Kotlin List in the app. JavaScript has no such type, so the shared object keeps the list for the Kotlin side and gives JavaScript a plain array through knownValues(). Every shared module has a few spots like this, so JavaScript gets a handful of functions to call, not the app's internals.
// shared/format/PartOfSpeech.kt
@JsExport
object PartOfSpeech {
@JsExport.Ignore
val known: List<String> = listOf("noun", "proper noun", "verb", "adjective", /* … */ "other")
/** The vocabulary for JavaScript callers. */
fun knownValues(): Array<String> = known.toTypedArray()
/** The four groups a list of cards is filtered by; everything else "other". */
fun group(raw: String?): String = when (normalize(raw)) {
"noun", "verb", "adjective", "phrase" -> normalize(raw)
else -> "other"
}
}
Speech engines count spoken words, not written ones. While a sentence is read aloud, the engine tells the app "I'm starting a word now" for each word it says. That's how the highlight moves. But the engine splits words its own way: it says "well-known" as "well" then "known", so it sends two signals for one written word, and the highlight would light half a word, then the other half. The shared code catches that and lights the whole written word. The browser's engine does exactly what the iOS and Android engines do here, so the same code handles all three.
One rule, three engines
Look at the first image again. On Android and the web the highlight sits on "Hallo". On the iPhone it sits on "Hallo," with the comma inside.
Same shared rule on all three; the difference is what each speech engine hands it. Google's engine and Chrome report the bare word. Apple's reports the word with the comma attached, and the rule paints whatever it is given, because on Android and the web it never had a reason to strip punctuation.
So one rule on three platforms only goes as far as the three engines agree.
Was it worth it?
Yes. The website is JavaScript, but every decision on it comes from the same Kotlin the apps run: which word you tapped, which word is being spoken, whether a card is valid. The web got those rules with their tests, and I never wrote a second copy of any of them. When a rule changes, it changes once, and Android, iPhone and the web all get it.
Try it
Open vocabloot.com/decks/de-greetings, tap Hallo, press play, tap a word. Then do the same in the app. Same Kotlin, both times.
If you've put Kotlin/JS next to a Compose app, I'd like to hear what you kept out of the shared modules, and why.
Vocabloot is on the App Store and Google Play. Decks: vocabloot.com/decks. Deck Kit: vocabloot.com/community/make.










Top comments (0)