Sole engineer · 2026 · shipped
TSA: Terminal Security Agent
A checkpoint inspection game on Google Play, with a self-hosted over-the-air update pipeline behind it.
- SvelteKit 2
- Svelte 5
- TypeScript
- Vitest
- Capacitor 6
- static adapter
The problem
Shipping a mobile game means shipping a binary you cannot touch again for days. A balance mistake, a typo in a rule the player is judged against, a crash on one device family — each is a review cycle away from a fix, and the players who hit it in the meantime uninstall.
What I built
A Papers-Please-style checkpoint game. Each passenger hands over a license, a boarding pass, and a carry-on that you inspect by X-ray; you clear them or detain them, and you are graded on being right. Live on Google Play, free with ads and a $0.99 remove-ads purchase, with a playable web build for anyone who wants to try it without installing.
Validity is baked into passenger generation as tagged issues rather than evaluated afterward, which keeps the rules layer thin — the generator already knows exactly what is wrong with the document it just produced, so scoring never has to re-derive it. Difficulty escalates by unlocking new classes of problem instead of scaling numbers: fake states appear at wave 2, passengers with multiple simultaneous issues at wave 3, and homoglyph name typos — "Michael" against "Micheal" — from wave 5. The game gets harder because there are new things to notice, not because the timer got meaner.
SvelteKit 2 and Svelte 5 in TypeScript, wrapped for Android with Capacitor 6, on an in-house engine. Forty Vitest test files.
Decisions
One push, one build ID, two artifacts. A push to main builds the game
twice — once as the static web demo, once zipped as the native update bundle
— under a single shared build ID. The two can never disagree, because there
is no path by which one gets rebuilt without the other. Given up: independent
release cadence. A web-only copy fix still cuts a native bundle, and I cannot
hold the demo back to test on device first.
notifyAppReady() fires unconditionally and first. The updater's
"this bundle is good" signal runs at the very top of boot, before any game
code that could throw. If a bundle fails to boot, the signal never arrives
and the platform rolls back to the previous one automatically. The honest
cost of putting it first is that it only certifies booting, not working:
a bundle that starts fine and then breaks in wave four is, as far as the
rollback is concerned, a healthy bundle. I took that trade because the
failure I actually needed to make impossible was the unrecoverable one —
a device stuck on a bundle that cannot start cannot download a fix.
Build IDs are UTC timestamps. Because the format sorts correctly as text, a lexicographic string comparison is downgrade protection: a device will not install a bundle whose ID sorts below what it already has, and I did not have to write, test, or maintain a version comparator to get that. Given up: meaningful version numbers. The build ID says when, never what, so it carries no signal about compatibility — and the whole scheme's correctness now rests on the builder's clock being right and monotonic.
Hard part
The interesting part of the update pipeline is a race with the player.
An update that installs on the next cold start is safe and slow: the player finishes a session on the old build, quits, and comes back to the new one. An update that applies immediately is fast and dangerous, because reloading the app mid-session destroys whatever run the player was in the middle of. Most systems pick one. This one picks per launch.
The fast path applies only if two conditions both hold: the version check and the bundle download both complete within five seconds of launch, and the session store is provably pristine — nothing started, nothing to lose. In that window the player is still on the title screen, so the reload is invisible, and they get the new build immediately rather than a session later. If either condition fails — a slow connection, or a run already in progress — it silently degrades to applying on the next cold start.
The property I was designing for is that neither failure can hurt: a slow network cannot hold the boot hostage, because the five-second bound is on the fast path and not on launching the game, and a reload cannot destroy a live run, because the pristine check is a precondition rather than a heuristic.
Where this system is weak, stated plainly: the bundles have no signature and no checksum. They are plain zips fetched over HTTPS. Every safety property in the pipeline is availability-oriented — auto-rollback on boot failure, downgrade protection, degradation to cold start — and none of them are integrity-oriented. Transport security is doing all of the work of answering "is this bundle the one I published," and nothing in the client can verify that on its own. The mechanism to fix it is not exotic; a sibling project of mine already does sha256 verification on its bundles, and this one should be doing the same.
Outcome
Live on Google Play and playable in a browser. A pixel-level art regression test runs in CI: it rasterizes the character matrix and scans pixel columns for floating hair, so a sprite-layer regression fails the build. The store video is not captured by hand — the game plays itself through a scripted driver.