Inspiration

Every language app I've tried eventually turns into a flashcard screen with a streak counter guilting me. I didn't want to build another one. I wanted the language to be the thing you aim with.

So you're an alien field researcher posted to Earth ahead of the invasion. The mothership radios an order in French ("Apporte la vache"), there are eight animals wandering the field below, and you fly the saucer down and beam one up. Grab the wrong one and the farmer comes out with a shotgun. Nobody quizzes you. You just have to know which one is la vache, at speed, while something is shooting at you.

The other half of the idea was boring and market-shaped: nobody owns "learn french game" the way the big owl owns "learn french". So every language is its own app and its own world. The French one is a French countryside, a French market, a French café.

What it does

Cowsmic is a 3D arcade game where French is the targeting system.

  • 5 sectors, 40 missions, 250+ words and phrases, all spoken by on-device TTS that works in airplane mode.
  • Radio confirm: hold the brass handset and say the specimen name back to command for a bonus. It's a radio check, not a drill. It rewards you and never blocks you.
  • The jam decoder: sometimes command's signal cuts out mid word, and you rearrange torn scraps of the sentence to work out what they wanted. That's where verbs and syntax live.
  • A universal translator you can tap to freeze the field, a transmission log, a specimen codex, and a 170-entry phrasebook. All free forever, on purpose. I have one hard rule about not charging curiosity tax.
  • A free moon base training annex, because dropping a new player straight into a shotgun was rude.
  • Five UI languages (en, es, de, pt-BR, it), because a French course shouldn't assume you speak English.

How we built it

Three.js and TypeScript in a Capacitor shell. No Unity, no React, no React Native. Vite builds it, one codebase ships to both stores.

The layout is the whole trick. core/ is pure TypeScript with zero Three.js imports: the spaced-repetition scheduler, the request picker, the distractor dial, scoring, the speech matcher, mission evaluation, entitlement resolution. All of it unit tested, about 580 tests. game/ is the render loop, presentation/ is HUD and menus, native/ is a thin Capacitor layer, and everything that varies is JSON. Adding a new animal is a GLB and a JSON row. Adding a level is a file.

There's also a boot smoke test, and it exists because I earned it. 331 pure logic tests stayed green while the game was completely unbootable (a variable declared below the function that read it, blank screen, tsc perfectly happy). Now a headless Chrome drives the real game title to map to level to first capture before I'm allowed to commit.

Most of the art is procedural. Every animal in the field is Three.js primitives, with markings drawn as lens dots because the camera is top down and vertex paint reads as mud from up there. Hero meshes (the saucer, the farmhouse, the vendor) are AI generated and hand cut. The app icon, the badges, the medals: all built with code and a crop tool.

Challenges we ran into

Speech recognition needs a network connection and fails hardest on beginner pronunciation, which is the entire audience. So speaking can never be a gate. It's a bonus, and a failed attempt comes back as alien static and a joke rather than a red X. The matcher is deliberately forgiving (accents stripped, articles optional, phonetically weighted so "vash" still counts) because a false positive costs half a bonus and a false negative tells someone who said it correctly that they're wrong.

Two silent failure traps, one per platform, neither visible to any test I could write. On Android, the Play Games UI has to be launched for a result: call plain startActivity and the button just does nothing at all, forever. On iOS, Capacitor 8 only registers the plugins its generated list names, which can never include an app-local one, so my Game Center plugin shipped and was never registered. Both were found by holding a phone.

The one that would have got me rejected: viewport-fit=cover was missing from the viewport meta, so iOS reported every safe area inset as zero while still drawing under the status bar. About twenty perfectly correct CSS calculations were dead on iOS only, and the paywall's close button landed underneath the battery icon. An unclosable paywall. No test on earth was going to catch that.

And my favourite: German had no neuter. The adjective table was written for French, which has two genders, so all 17 German neuter nouns were tagged masculine and glossed as "bring großen Schaf" to actual German speakers. The reason it survived that long is that no test had ever loaded the German pack. The suite read French and English and felt very thorough about it.

Accomplishments that we're proud of

It's on both stores. That's the one. Solo dev, no engine, no team, no publisher, and there is a real App Store page and a real Play listing with a real price on them. Everything below is a footnote to that.

But some footnotes I like:

There is no Unity in this. The whole game is Three.js and TypeScript in a Capacitor shell, which every sensible person told me was the wrong tool for a 3D game on phones. It runs at 9ms median and 15ms at the 95th percentile, which leaves about 7ms of headroom at 60fps on a Pixel. One codebase, both stores, and I never once opened a game engine.

Almost none of the art was bought or generated. Every animal in the field is Three.js primitives assembled by a build script. The cow is boxes and spheres. Their spots are lens dots, because the camera looks straight down and vertex paint reads as mud from up there. The contract medals are CSS. The app icon is a crop of a painting I already had. The achievement icons come out of a Node script. I had a hard budget for AI generation and I mostly spent it on the saucer, the farmhouse, the market vendor and the café waiter.

The animals walk without a single bone in them. No rig, no animation software, no Blender. There's a per-species gait table and some pure math for squash and stretch, so the chicken struts, the sheep boings, the pig scampers and the horse trots. It's about 40 lines of maths and it's the thing people notice first. Two bugs got me there: every animal crab-walked sideways for a day because the models are built nose-first along X while the heading maths assumed Z, and the flee behaviour was a velocity shove that made them look like they'd been hit by a car. Now they carve a proper arc into the run and the waddle speeds up with them.

The jam decoder. Your order arrives, the mothership's voice cuts out mid-word, the screen goes wavy, and you're left rearranging torn scraps of a sentence with tape on them. That's how the game teaches verbs and word order without ever putting a grammar table on screen. It's the one mechanic I've not seen anywhere else, and it came from the owner rejecting my first two ideas as too fiddly. He was right both times.

Five languages, all the way through, before launch. Not English first with translations bolted on later. Every user-facing string is a key, the UI ships in en, es, de, pt-BR and it, and both store listings exist in all five. Even the title poster's tagline was baked into the artwork as pixels, so I wrote a script that paints the English text out of the poster and redraws the localized line on top. 70 store screenshots, shot natively at each device's real resolution because rescaling an iPhone frame to a Pixel aspect either clips the HUD or letterboxes it.

The free tier doesn't tax curiosity. The translator, the transmission log, the specimen codex, the first-encounter gloss, the 170-entry phrasebook and the help-when-you're-struggling assist are free forever, including for people who never pay a penny. Paying gets you more of the game, never permission to understand it. There are also no ads and no analytics of any kind, which means the privacy policy gets to say "no third-party analytics" and mean it. I'll take that trade over the dashboard.

The architecture bet paid off in public. When I added Game Center, the TypeScript layer didn't change by one line. Not one. The iOS plugin registers itself under Google's JS name, so core/playGames genuinely cannot tell which platform it's on, and only the id map switches. When something you designed six weeks ago just absorbs a whole new platform, that's the nicest feeling in this job.

What we learned

Tests protect logic. They don't protect the game. I have around 580 unit tests and I love them, and every genuinely bad bug in this project was found by looking at the actual thing. The suite was green the entire time the game was unbootable, because a variable declared below the function that read it doesn't bother TypeScript and doesn't bother a pure-logic test. That's why there's now a smoke test that boots the real game in headless Chrome and drives it title to map to level to first capture. It caught nothing for weeks and then earned its whole existence in one afternoon.

Your test fixtures are a map of what you believe exists. The German neuter bug is the best lesson I got out of this. My adjective table was written for French, which has two genders, so all 17 German neuter nouns were tagged masculine and glossed to German speakers as "bring großen Schaf". It sat there for weeks. Not because the tests were weak, but because no test had ever loaded the German pack. The suite read French and English and felt extremely thorough about it. If your fixtures only cover the case you were thinking about, your coverage number is measuring your imagination.

Some failures are completely silent, and they're always at the native seam. Android's Play Games UI has to be launched for a result. Call plain startActivity and the button does nothing at all, forever, with no error anywhere. On iOS, Capacitor only registers the plugins its generated list names, which can never include an app-local one, so my Game Center plugin shipped and was simply never registered. Same story with the Game Center entitlement: GameKit doesn't throw without it, it just never authenticates, which looks exactly like a signed-out player. Three failure modes, zero error messages. You find these by holding a phone, or you don't find them.

People read hierarchy before they read labels. A new player finished level one, the paywall opened on top, they closed it, and they were left on the debrief screen where the only gold button said BEAM AGAIN. So they replayed level one. The way forward was a small cream chip further up. The button was correctly labelled and it pointed backwards, and everyone followed the button. Related: my privacy policy and terms existed since July as two 11px links at the bottom of the settings scroll, under the delete-everything button, and a tester report said the app had neither. They were right. A document nobody can find is missing.

The boring plumbing is where the actual risk lives. The core mechanic took a week of tuning and every hour of it was fun. Entitlement resolution took an afternoon and it's the only code in this project that could have taken someone's $49.99 and given them nothing back. My first RevenueCat entitlement used display text as the identifier, and unknown identifiers fall back to the free tier in my code, so that purchase would have charged the card, thanked the customer and unlocked absolutely nothing. Then premium granted the wrong tier and would have left payers looking at four locked sectors out of five. Neither of those is a hard problem. Both would have been a refund and a one-star review.

Ship the safe state by default. My dev panel with all the cheats in it (free coins, unlock everything, wipe the save, grant the paid tier) was mounted unconditionally, which means it was in release builds. Anyone who found it could hand themselves the paid tier. It's now behind a build flag where the dangerous version has to be asked for by name, and the release bundle drops the whole import. The lesson isn't "remember to turn it off". It's that if you have to remember, you've already lost.

Design around your failure mode, not your success case. Speech recognition needs a network connection and fails hardest on beginner pronunciation, which is literally my entire audience. So speaking can never gate anything. It's a pure bonus, and a failed attempt comes back as alien static and a gag rather than a red cross. A false positive costs half a bonus. A false negative tells a person who said it correctly that they're wrong, and they close the app. Those costs are wildly asymmetric, so the matcher is deliberately over-generous.

What's next for Cowsmic: Learn French Game

Spanish. This is the whole plan and it's been the plan since the first commit. Content packs are language-agnostic concept ids, the world is a theme JSON, and the SKU table is one file, so the Spanish version is a new world and a new pack rather than a new game. The country is the language: French is a French countryside and a French market, Spanish gets its own places. If the French one finds an audience, the second one costs a fraction of the first.

Find out whether anyone actually talks to their phone. The radio confirm mechanic is the piece I'm least sure about. Holding a brass handset and saying "la vache" out loud to a cartoon mothership is either the best bit of the game or the bit everyone silently ignores on the bus, and I genuinely don't know which. I have no analytics, on purpose, so I'll be finding out the slow way, by asking people.

A German native speaker pass. French has had one. German hasn't, and the neuter fix changed a lot of German content, so it needs a real human who speaks it to read the whole pack before I trust it.

The app preview video. Still unbuilt. Both listings currently rely on screenshots, and a 15 second clip of a cow being lifted out of a field while a farmer sprints at you with a shotgun is a much easier sell than a still frame.

More places. Five sectors and 40 missions is a real game, but the ladder is codified now, so a new place is mostly content: a theme, a roster, a map painting, eight levels. A jammed sector where every order arrives scrambled is the one I want to build next.

Some paywall experiments. The paywall is offering-driven, which means I can change the plans, the copy and the trial length from the RevenueCat dashboard without shipping a build. I've not used any of that yet. Right now it fires exactly once, after your first win, and I've no idea whether that's the right moment or just the first moment I thought of.

Built With

Share this project:

Updates