
Apex Coloring is out now on Web, iOS, Android. This is how it got made: the decisions, the pipeline, and what I would do differently.
What it is
Apex Coloring — a 3D art studio for kids 3+. Color 3D animals, paint, solve art puzzles, play mini games, learn letters and numbers. No ads, no data collection, made for little hands.
Color 3D animals, paint, solve art puzzles, play mini games, learn letters and numbers. No ads, no data collection, made for little hands.
GDPR / CCPA — and deletion that actually deletes
Legal bases: contract (gameplay, purchases), legitimate interest (fraud, security, first-party analytics), consent (personalised ads, push, marketing). Endpoints, all behind an authenticated session, with web versions that work after the app is uninstalled:
- Endpoint: Behaviour
- GET /api/account/export: queued export → signed 48 h URL to JSON+CSV of every row keyed to the account (including the consent records). 1 per 7 days.
- POST /api/account/delete + web /account/delete: typed DELETE + re-auth. Immediate: sessions revoked, account hidden. Within 7 days (undo window): cascade delete; purchases pseudonymised (legal hold).
- GET/PATCH /api/account/privacy: current consents + age band
- POST /api/account/restrict: "limit processing": keep the account, stop analytics and chat retention
The real lesson: deletion must be probe-verified, not assumed. The deletion test deletes a fully-populated account and then asserts zero orphans, enumerating targets from the schema itself so a new table cannot be forgotten:
- every table with an account-id column — enumerate sqlite_master (or the catalog), don't hand-list;
- every file store — avatar uploads, save blobs, export zips left on disk;
- every pointer — cache entries, queues, live sessions that could resurrect the row (evict from in-memory caches before deleting, or an autosave tick writes the account back);
- consent rows and…
Camera per game type
game.config.json gameType selects config/game-types/.json, whose camera block the rig is built from:
- gameType: camera.type camera.style camera.controls
- city-builder: orthographic isometric pan-zoom-rotate
- idle-tycoon: orthographic fixed-iso pan-zoom
- racing: perspective chase follow
- arcade: perspective third-person follow
- puzzle: perspective top-down fixed
For the orthographic styles, zoom is the scale (camera.zoom = pixels per world unit, exponential steps so wheel and pinch feel identical), position is derived each frame from {target, zoom, yaw, pitch} at a fixed distance, and rotation snaps to the four cardinal views with a tween. Pitch clamps ~25°–60°; target clamps to world bounds plus a margin. For perspective styles the rig is a follow controller with the same clamping discipline. Either way the rig owns one small mutable object read in useFrame — never React state.
An orthographic camera has a useful side effect: LOD can be global per zoom tier rather than per-object distance — one state swap, no mid-screen
Billing flow (server-authoritative)
- Store panel opens → billing.products with this store's product ids from the server catalog (storeIds.google are plain SKUs, storeIds.apple are reverse-DNS). Asking one store for the other's ids returns an empty list and the panel silently falls back to reference prices — which is how a wrong-store bug hides, so which store to ask is a build-time fact (native.platform), never a runtime guess.
- billing.purchase {sku, accountId, type, offerToken?} opens the sheet; the account id rides the receipt (obfuscated account id / app account token) so the server can bind it.
- The outcome arrives as the purchase event carrying platform and the receipt (Play purchase token / StoreKit signed transaction).
- The page routes on the event's own platform, POSTs to the matching server verifier (which checks with the store, credits once — idempotent on order/transaction id — and answers {consume}), then always calls billing.finish. Both halves matter: an unconsumed purchase cannot be re-bought; an unfinished transaction is re-delivered on every launch forever.
- On launch, unfinished purchases replay with replay:true and take the same path — a crash between 3 and 4 loses nothing.
Subscriptions additionally require the recurring terms on the purchase screen, before the buy button (price, period, auto-renews-until-cancelled, how to cancel, live terms/privacy links) — both stores reject builds…
Age gate and COPPA
- Neutral age screen at first run: ask for birth year only, store an age_band (u13 / 13_15 / 16_17 / adult), never a date. The band is immutable from the client; support can change it with an audited reason.
- Do not offer bands below 13 unless you are doing full COPPA (verifiable parental consent, direct notice, the works). The cheap, defensible middle: u13 accounts get a limited mode — no chat, no publicly shown name, no purchases, no ads, no email collected (guest only) — which keeps you out of COPPA's "actual knowledge" trap without building a consent flow.
- 13_15: no purchases, no ads, stricter chat filter. 16_17: purchases allowed (device parental controls apply), non-personalised ads only.
- Make the defaults privacy-protective for everyone (non-personalised ads, no profiling, minimal data); then the minor bands only add restrictions rather than needing a parallel stack (UK AADC / EU DSA
Latest changes
Changelog
Every release of Apex Coloring, newest first. Each entry carries one block per store locale in the format the Play Console release-notes field takes, so the notes can be pasted straight in — and App Store Connect, the website and the in-app What's New panel all read the same source.
Generated by npm run release:sync from shared/content/release-notes.json — edit that file, never this one. Play allows at most 500 characters per language.
What is next
It is live at https://apexkids.reallexi.io. If you have shipped something similar, I would like to hear what you would have cut.

Top comments (0)