DEV Community

Reallexi LLC
Reallexi LLC

Posted on

Building Apex Coloring: 3D Art Studio for Kids 3+

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.

Textures — baked in Blender, tiered per platform

Models ship untextured; one atlas set per asset family ships beside them. scripts/blender/bake_textures.py builds 16 procedural node-tree materials per family on a 4×4 tile grid and runs two Cycles EMIT bakes (1 sample, no lights — a bake, not a render): base colour, and a packed data map. scripts/gen-textures.mjs then derives the tangent-space normal (from the baked height field) and the ORM (R occlusion / G roughness / B metalness / A emissive mask), and encodes one set per quality tier, capped at each tier's textureMax from config/platform-quality.json (high 2048 / medium 1024 / low 512):

The client fetches exactly one tier (its own), chosen by the quality system — this is the other half of the platform-quality contract: the pipeline caps what each tier can even download.

UV contract: faces carry TEXCOORD_0 = world-scaled tiling coordinates (metres per repeat, so brick courses align with floor lines) and TEXCOORD_1 = the tile origin, constant per face; the shader samples origin + fract(uv) * 0.25 with textureGrad so the tile wrap never picks a coarse mip. Only designated slots are tintable by the palette; everything else keeps its real albedo. The attributes survive weld/join/simplify/Draco, so all LODs share the atlas.

KTX2 is a VRAM decision, not a download decision. Measured on an eleven-family set (4096 px albedo class):

  • map: WebP download KTX2 download WebP VRAM…

PII inventory and retention

Keep this table current — it is the privacy policy's "what we collect" section and the store data-safety form:

  • Data: Purpose Retention
  • email (optional): login, receipts, recovery life of account
  • password hash (Argon2id): login life of account
  • display name, world/city name (UGC): gameplay life of account
  • OAuth subject ids (Google/Apple/Facebook): login life of account
  • age band (never a birth date): minor protections life of account
  • IP hash (SHA-256, daily rotating salt) + UA family: rate limiting, fraud 30 days
  • purchase rows (amount, currency, country, provider ids): fulfilment, refunds, tax 7 years (tax law) — pseudonymised on account deletion, account id replaced by a tombstone
  • chat messages: gameplay 30 days or last 200 messages per channel, whichever is less
  • avatars / player images: profile life of account — and they are "Photos and videos" in the Play data-safety form, even when they are only profile pictures; declaring them as "other" fails review
  • analytics events (no free text, no names): first-party analytics 13 months raw; aggregates forever
  • push tokens: notifications until unregistered / 60 days unused

Never collected: precise location, contacts, phone numbers, card data (the payment providers hold it), raw IPs at rest. Player-chosen names are UGC: run them through the same profanity filter as chat plus a PII regex (emails, phone numbers) —…

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

The postMessage bridge

One JSON envelope in both directions; constants shared from shared/bridge.js so web and native cannot drift.

Rules:

  • id is opaque, unique, generated by the page; replies always echo it. Every request gets exactly one reply. Long-running flows (a purchase sheet) reply immediately with {started:true} and deliver the outcome as an event.
  • Error codes are stable strings: unavailable, bad_namespace, bad_op, bad_request, cancelled, denied, simulator, sign_in_failed, purchase_error, native_error. Unknown ns/op returns unavailable — an older shell never hangs a newer web bundle.
  • Payloads are JSON only. Never put a session token in a payload; the page owns auth.
  • The page times out its own requests (default 60 s) and rejects with TIMEOUT; on the web (window.ReactNativeWebView absent) calls reject immediately with NO_BRIDGE, so every call site has a web fallback by construction.
  • The reply lands on a different target per platform. react-native-webview's postMessage dispatches the reply as a MessageEvent on window on iOS but on document on Android, and the event does not bubble — so a window-only listener silently drops every Android reply and each call dies of its own timeout. The page must register the SAME handler on both window and document. Symptom when it is missing: Android shows "the store is not available" after a 30 s pause while iOS works perfectly (shipped in Apex…

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)