Bati
Train like a hero, build like a king.
A dark-fantasy fitness RPG. Your workouts are quests, your reps are damage, and the village you build is made of what you actually lifted.
getbati.app, the site, in English, French, German, Spanish and Russian
A workout is a quest. Your reps land as damage on a boss. The muscles you actually train raise the buildings of a village that grows out of what you lifted, not out of what you bought.
Sport first, RPG second: the training logic leads and the game amplifies it. Nothing decorative gets between you and your next set.
It is built for people who train alone and lose the thread, at home, in a gym, or on a road out of town. No coach, no feed, no leaderboard, nobody to compare yourself to. The only thing keeping score is a village that can only be built by showing up.
Your training stays on your phone. No account, no servers of ours, no analytics, no ads. Your history lives in a database on the device. Three things can reach the network, each behind its own switch in Settings, all three off by default.
First, the map behind an expedition's route. Switched on, the phone asks OpenFreeMap
(OpenStreetMap data, served from tiles.openfreemap.org) for tiles, the square images a map is
made of, covering the place where you went. That request tells the host roughly where you were,
with your IP address and the time. Not the route to the metre, not the pace, not the training, not
who you are, but the area, yes. Off, the route is drawn on a plain background and nothing is
requested.
Second, Check for updates: switched on, the app asks api.github.com once a day whether a newer
version has been published, which is how a copy installed from an APK by hand finds out at all.
That request carries no identifier, downloads nothing, and the card it raises opens the release
page in your own browser.
Third, Sync my devices: switched on, each device encrypts its whole history on the phone (AES-256-GCM, with your backup key) and sends it to a WebDAV or Nextcloud server you choose, which stores bytes it cannot read. Pointed at a folder that Syncthing keeps in step instead, Bati makes no request at all.
Nothing else may open a connection: a lint rule rejects every network call written in JavaScript
outside src/updateCheck.ts and src/cloudSync.ts, so a fourth destination cannot arrive by
accident. The map is fetched natively by MapLibre, from the one host its style names. Three
switches; everything else is the architecture.
Early days. The app works end to end and is published through F-Droid, which is where its first users came from. It is not on Google Play: that account exists and has never left its internal track.
See it
![]() |
![]() |
![]() |
![]() |
| Your next session, one tap away. | One exercise at a time. | Some sessions fight back. | Your reps built all of this. |
What it does
Every workout is a quest. A campaign with a story, chapters and a map. You pick the next quest, and it knows what you trained last.
Your reps land as damage. Finish a set and the boss takes the hit. The bar moves because you moved, and the fight is over when the session is.
Some sessions fight back. Boss fights close a chapter and ask for a real effort: the one session in the arc you have to show up for.
The village is made of what you lifted. Train shoulders, the forge rises. Nothing in it can be bought, skipped or rushed; every building is a receipt for work you actually did.
Some quests leave the walls. Walking, running and riding are expeditions: a session measured
in ground covered rather than repetitions. The distance comes from the app's own GPS module,
written against Android's LocationManager with no Google library anywhere near it, and the
leagues it records raise the High Road, the one building in the village that nothing you lift can
level.
Years of history, on one screen. Streaks, records, muscle balance and every session you ever finished, read from a database on your phone, with nothing to log in to.
No account, and three network destinations, all off. There is no server of ours to leak, no
analytics to opt out of, and no cloud copy of your training unless you name the server. Bati reaches
out for three things and only if you ask it to: the map behind an expedition, from
tiles.openfreemap.org, which tells that host roughly where the outing happened; a daily question
to api.github.com about a newer version, which tells it an IP address and a time; and device
sync, to your own WebDAV or Nextcloud server, which receives your history encrypted on the phone.
Nothing else leaves the phone. Privacy policy.
Install it
Bati is for people aged 16 and over.
All of this is also on the site, in English, French, German, Spanish and Russian.
F-Droid, the one that updates itself
Bati is in the official F-Droid catalogue:
that is what the badge above opens, and searching F-Droid for "Bati" finds it. Installed from
GitHub Releases before that? F-Droid signs its builds with its own key, so the catalogue cannot
update that copy (Android refuses a different signature, and uninstalling takes your hero with it).
Keep the self-hosted repository instead (same APKs, same key, updates arriving like any other
app's), described in docs/fdroid.md.
By hand instead, under Settings → Repositories → +: paste the address, and check that F-Droid shows this fingerprint before you accept it:
https://getbati.app/fdroid/repo
089D B128 38D6 60CA F285 BE85 5D8E 6D02 3407 A50D 9805 1B38 4309 5EA0 9BBA 2D97
That fingerprint is the whole security model of a self-hosted repository: it pins the key every future update must be signed with. Adding the address without checking it trusts whatever answers at that URL.
A plain APK
Releases: Android will ask you to allow installs from an unknown source the first time. Nothing updates itself this way; you download the next one yourself.
See docs/fdroid.md for how the repository is built and signed.
Everything below is for reading or building the code.
Written with AI
This project is largely written with AI assistance. Saying it up front so you read the code with that in mind. It is a fun project: I build it because I enjoy building it.
Where the art comes from
Every illustration in assets/ (exercise art, quest and adventure covers, boss portraits,
village buildings, avatars) is AI-generated. Nothing is stock.
All of it comes from FLUX by Black Forest Labs (FLUX.2, and FLUX 3 for the latest retouches), through our own API account. That detail is the licence: BFL's terms give the output to whoever holds the key, so generating through an aggregator would have left us with art we could not license onward, which is why an earlier Midjourney-and-Gemini-via-Mammouth set was regenerated from scratch.
Many exercise pictures are drawn against a pose reference: line art from workout-guide, a
photograph (mine, or one from Wikimedia Commons), or a posed 3D mannequin. The references
themselves stay out of the repo, some of them being photos of me. Their credits stay in
scripts/pose-refs/, and the ledger pins each one by hash.
Icons are a separate system: game and fantasy icons go through the project's own icon hook,
utility icons come from @tamagui/lucide-icons.
Quick start
npm install
npm start # Expo dev server
Running on a device needs a dev build (npm run android / npm run ios), because the app uses
native modules: SQLite, audio, an Android home-screen widget, MapLibre, and modules/bati-location,
the location service this repo owns. Expo Go loads none of them.
Scripts
| Command | What it runs |
|---|---|
npm start |
Expo dev server |
npm run android / ios / web |
run on a target |
npm run check |
Biome + TypeScript |
npm test |
Jest |
npm run deadcode |
dead-code check |
npm run maestro |
Maestro E2E flows (needs a device) |
npm run db:generate / db:push |
Drizzle migrations |
Quality gates
All of these run in CI; the first four also run on commit or push.
- Biome: formatting and lint, including four GritQL plugins written for this repo. One
rejects raw hex colours outside
constants/rawColors.ts; another (noJsNetwork.grit) rejectsfetch,XMLHttpRequest,WebSocket,EventSourceandsendBeaconanywhere in the app but the one module that asks GitHub about new versions, which is what keeps "two hosts, both off" a fact rather than a sentence in this file. - TypeScript, strict.
- Jest: ~1200 tests, with coverage thresholds set just under actual so they catch deletion rather than reward padding.
- Knip: dead code. At zero; anything it reports is new.
- expo-doctor: 20 checks on dependency versions and project shape, all green. A version the installed SDK does not expect builds fine here and fails for whoever rebuilds from source.
- Maestro: end-to-end flows against a real device.
Project layout
app/: Expo Router screens (file-based routing)components/: UI, grouped by featuredb/: SQLite + Drizzle domain logic; the source of truth for game rulesstores/: Zustand state (session, settings, user)constants/: game design constants and the colour palettemodules/: local Expo modules;bati-locationis the Kotlin GPS servicedocs/: the wiki: product, design, gameplay, architecture, planning.maestro/: E2E flowsplugins/: local Expo config plugins
Contributing
CONTRIBUTING.md for code, docs/CONTRIBUTING.md for
the wiki, SECURITY.md to report something that should not be a public issue.
Or just write to contact@getbati.app. An idea is welcome in whatever form it arrives.
Licence
MIT for the code, CC BY-SA 4.0 for
the artwork in assets/.
The illustrations are generated with FLUX by Black Forest Labs through our own API account. BFL's
Developer Terms give the account holder the output and allow personal and commercial use (§3.b, EU
terms §4.b). BFL also signs every output with C2PA Content Credentials. Resizing and converting the
pictures to WebP for the app drops them, so the signed originals are kept, and this is the place
that says so: the art is AI-generated. scripts/provenance.json records the model, prompt, seed
and references behind every picture, so fork away: the art comes with you, and you can regenerate
it yourself.
Many exercise illustrations are derived work on top of that. FLUX redrew them from line art by
Bryl Lim in workout-guide, which
builds on the anatomical studies of Everkinetic, both
CC BY-SA 4.0. The share-alike obligation is already satisfied by the licence above. It binds the
images, never the app code. scripts/provenance.json names the source for each one.
The tuck planche takes its pose from a photograph by Mr. Yoga (CC BY-SA 4.0), reduced to its outline before FLUX redrew it. Seven more take theirs from photographs of the maintainer, released under the same licence. The app's Credits screen carries the same attributions, since the APK does not ship this file.
Two exceptions keep their own terms: the game-icons.net set (CC BY 3.0 / CC0) and the store badges.





