Architecture — what Fyndi is made of#
Fyndi is four cooperating parts — an Android app, an API, a website with a staff panel, and a server that hosts them — and each part has one clearly defined role, which is why content can be changed without shipping a new APK.
What Fyndi is made of#
| Part | Technology | What it is responsible for |
|---|---|---|
| Android app | Kotlin, Jetpack Compose, Mapbox, ARCore Geospatial | map and the character pawn, routes, finds, AR view, navigation, notifications, offline pack and the character creator (drawn natively, the same model as on the website) |
| API | Node.js 22 (in server/api), PostgreSQL 16 (the database container) | accounts and sessions (Argon2id plus JWT and refresh tokens), routes and stops, completing stops, finds, offline packs, player presence, the panel's AI features — and since 14 September also lobbies (exploring together), NPC bots (movement computed from time), tokens and rewards, the planner bridge to GitHub Issues and serving the project brain graph |
| Website and panel | static HTML/CSS/JS served by nginx | landing page, /account, /character (the same creator as in the app) and the staff panel: dashboard, content studio, planning, media, 3D models, character generator |
| Server | nginx with TLS (Let's Encrypt), containers, OSRM | serving the website and the API under /api/, the content store, documentation screenshots, the APK mirror and walking/cycling routing (since 13 September: all of Poland, one OSRM data file of roughly 11 GB) |
The same picture as a diagram:
Android app (Kotlin)
UI | Domain | Data
|
v
ArExperience (interface) --> ArCoreImpl (today)
--> UnityImpl (later)
|
| HTTPS, JSON
v
API on the production server
Auth | Routes | Packs | Content | Coop
Bots | Rewards | Brain | Texts | Users/RBAC | Assets
|
+----+----+
v v
PostgreSQL file storage (models, media)
The API is not one program but a set of modules with separate responsibilities — this is also what the documentation gate reads when it checks that a described feature has an owner:
| API module | Responsibility |
|---|---|
auth/ | registration, login, refresh, roles (user / mod / admin) |
content/ | stops, routes, sponsors, media |
progress/ | a player's routes, completing stops, finds, history, reward hooks |
packs/ | offline packs |
staff/ | panel: tasks, notes, media, texts, releases |
brain/ | the project brain graph (staff only) |
ai/ | character generator and the content studio |
texts/ | interface text overrides, so labels can change without a new APK |
coop/ | exploring together — lobbies with a code, no coordinates in the responses |
bots/ | NPCs walking along zones, with a gift as a one-off find |
rewards/ | token economy: rules, ledger, catalogue, redemptions |
Data flow#
The app talks to the API over HTTPS in JSON, and the server is the source of truth. The phone keeps a local cache and a queue of events so that it works without a network, but statistics, completed stops and character appearance live in the database.
- The app reads the content it needs (routes, stops, finds, bots) and writes only its own events.
- When the network is back, the queued events are pushed to the server.
- Every event carries its own identifier, so a repeated sync cannot create a duplicate — the same completion sent twice lands once.
There is deliberately no second source of truth: the app does not keep a private copy of progress that the server could disagree with. Sessions use short-lived tokens with a refresh token; a session list lives on the server side, which is also what the account screen shows.
Client layers (app and website)#
Both clients show the same things, but each uses the tools of its platform. The numbers below come from the same recipe on both sides — the parity is checked automatically.
| Layer | Android app | Website / panel |
|---|---|---|
| Map | Mapbox Maps SDK 11.31.1 with the Standard style, plus our own GL layers (CustomLayer) for figures and markers | Mapbox GL JS 3.30, with marker sprites drawn from the same palette as in the app |
| Character | native blocky renderer: its own GLB parser and shader, no texturing through a WebView | a model viewer on /character, the same atlas and the same blocks |
| Logic | Jetpack Compose; pawn movement, the camera, markers and the draw plan are pure Kotlin, testable on a server without a phone | static HTML/CSS/JS with no framework; the marker and bot modules have node tests |
On the Android side the code is split into Gradle modules:
:app— UI, navigation, character creator, map;:core— networking, auth storage, shared models, including the lobby models and API client;:ar— the ARCore implementation behind theArExperienceinterface;:puck3d— the shared 3D pawn renderer (GLB, animation clips, emote, shadow, rings and NPC layers). Since 0.120.0 both the main map and the AR mini-map use it, so one pawn has one definition.
Why it is built this way#
- The AR engine sits behind an interface (
ArExperience). Today it is ARCore Geospatial; if it ever needs replacing, the exploration logic does not have to be rewritten. - Content is data, not code. Routes, stops and models live in the database and on disk, so a change goes live without a new APK.
- No secrets in the app. The APK knows only the public API address; keys and the release keystore stay on the server and in the release secrets (APK releases).
- One creator everywhere. The app,
/characterand the panel read a single table of wardrobe items, so a character looks identical on every screen (3D character). - The domain layer knows nothing about the map engine. It sees only the AR port, so the game logic stays testable without a device and without a GPU.
Documented for app version 0.120.1 (updated 2026-09-26).
Machine translation — the Polish version prevails.