Audience is children aged 3-12 (kids-direction.md), which changes the art direction and the monetization stack but none of the engine choices below.
Key insight: this genre is technically trivial. It is an image viewer with pan/zoom, polygon hit-testing, and a list UI, not a real "game engine" problem. The hard part is the content pipeline, not the code.
Plain Flutter, no Flame. Chosen because the developer has a very strong Flutter background, which outweighs framework micro-differences for an app this simple, and Android comes nearly free later.
Rationale:
InteractiveViewerhandles pinch/pan/zoom out of the box, best-in-class ergonomics for this genre.- Flame is a game-loop/component engine and adds nothing for static scenes with tap targets; skip it.
- Tap handling:
GestureDetectorwithonTapUp, transform tap coords viaInteractiveViewer'sTransformationControllermatrix into image space, normalize, point-in-polygon test (~50 lines of Dart). - If a purchase is ever added, it goes through the official
in_app_purchaseplugin, behind a parental gate. See Purchases below. - No ad SDK. The audience is children aged 3-12 (kids-direction.md), so third-party ads are out regardless of which store category the listing sits in.
- Persistence:
shared_preferencesfor MVP, drift or Isar if state grows. - Found-object effects (sparkle, checkmark) via standard Flutter implicit/explicit animations.
- Level JSON loaded from the asset bundle.
| Approach | Fit | Why not |
|---|---|---|
| Native Swift (SwiftUI + UIScrollView) | Excellent for iOS-only | First-party StoreKit 2/AdMob and smallest binary, but iOS-only and no existing Swift expertise advantage here |
| SpriteKit | Good but overkill | Scene graph and physics buy nothing for static scenes |
| Unity 2D | Safe but heavy | Editor bloat and binary size for a static-image app; choose only if we later need ad mediation depth or mini-games |
| Godot 4 | Very good, cross-platform | Camera2D + Area2D/CollisionPolygon2D map perfectly to polygon hotspots; cost is monetization plugin maintenance (community AdMob plugin, version-pin tax) |
| React Native / Expo | Good if RN-fluent | Assembling third-party gesture/zoom libs plus native glue for StoreKit2/AdMob |
| Flutter + Flame | Rejected variant | Flame adds unneeded game-loop machinery; plain Flutter InteractiveViewer already solves zoom/pan |
Android: comes nearly free with Flutter; ship iOS first, enable Android after retention is proven.
A level ships what it hides, not where: the room is hidden freshly, from a seed, every time it is played. Everything is in normalized [0, 1] coordinates, so the same JSON works at any screen size and image resolution.
{
"sceneId": "kitchen_01",
"background": "background.jpg",
"meta": "meta.json",
"size": { "w": 4096, "h": 2730 },
"objectCount": 14,
"props": [
{
"id": "teapot",
"label": "Teapot",
"sprite": "sprites/teapot.png",
"aspect": 0.82,
"places": ["rests_on", "lies_flat"],
"regions": [],
"width": [0.04, 0.07],
"rotate": [-10, 10],
"copies": [1, 2],
"silhouette": [[-0.42, -0.31], [0.18, -0.40], [0.44, 0.28], [-0.36, 0.34]]
}
]
}silhouette is the convex hull of the sprite's opaque pixels in sprite-local units, where the sprite box runs from -0.5 to 0.5.
The placer scales, rotates and moves it into the scene to make the tap polygon, so the hotspot always matches the art wherever the prop lands.
meta.json beside it describes the room itself - regions, depth, rest lines, no-go zones - and is what the placer places against; see scene-meta.md.
Hit-test flow: tap point in screen space, invert the InteractiveViewer transformation matrix to image space, divide by image size to normalize, run point-in-polygon against each unfound object.
Whether the app ever carries a purchase is not settled publicly, and this repository does not document it. What is fixed, whatever the answer turns out to be:
- A parental gate sits in front of any purchase and any outbound link. The listing is 4+ and enrolled in the Kids Category (kids-direction.md), so App Review guidelines 1.3 and 5.1.4 and COPPA make this binding rather than self-imposed.
- No third-party advertising and no third-party analytics SDK, ever.
- Nothing that reaches the network gets added without meeting both of the above.