Earno Plaza
A Flutter app where users play short, easy mini games, earn coins into a game wallet, and redeem those coins for offers you provide. This document is the complete plan: what to build, how to design the economy, and a step-by-step build guide.
Core loop
Open app → pick a game → play 30 to 90 seconds → earn coins → coins land in wallet → redeem coins for offers → come back tomorrow for streak bonus.
Every design decision below serves this loop. Games must be instantly understandable, sessions must be short, and the wallet must feel rewarding and trustworthy.
Goals
- Easy to play: no tutorial longer than one screen, one-hand play, sessions under 2 minutes.
- Engaging: score chasing, streaks, daily missions, leaderboards.
- Sustainable: coin issuance is capped and funded by rewarded ads and partner offers.
- Secure: coin balance lives on the server and is never trusted from the client.
User flow
Onboarding
Splash → 3 slide intro (play, earn, redeem) → sign in with Google or phone OTP. Grant a small welcome bonus (e.g. 50 coins) so the wallet is not empty.Home
Wallet balance at the top, daily check-in button, "Featured game", then a grid of all games with a small "coins available today" indicator on each card.Play
Tap a game card → short "How to play" sheet on first launch → play → result screen showing score, coins earned, and a "Watch ad to double" button.Wallet
Balance, transaction history (earned, spent, pending), and the Offers tab listing redeemable rewards.Redeem
Pick an offer → confirm → coins deducted → offer status shows Pending → you fulfil it → status becomes Delivered with the coupon code or details.Return
Push notification the next day: "Your daily spin is ready" or "Streak day 3 bonus waiting". Streak increases the coin multiplier.
Game catalogue
Grouped by how they are built. Effort tags: Low one to two days, Medium three to five days, High a week or more for one developer.
Reflex and tap games
Short sessions, instantly understood, very high replay. Build these with plain Flutter widgets and a Timer.
Tap the Target
Circles appear at random positions and shrink. Tap before they vanish. Speed rises every 10 hits.
Colour Match
The word "RED" is shown in blue text. Tap the button matching the ink colour, not the word. 60 second round.
Whack-a-Mole
3x3 grid, moles pop up for shorter and shorter periods. Miss three and the round ends.
Stack Blocks
A block slides side to side. Tap to drop it on the tower. Overhang is cut off. Perfect drops give bonus coins.
Flappy Jump
Tap to flap through gaps. Score is pipes passed. Classic, and everybody knows how to play.
Reaction Time
Screen turns green after a random delay. Tap as fast as you can. Average of 5 tries gives the score.
Puzzle and brain games
Higher perceived value, so users feel the coins were "earned". Great for retention.
2048
Swipe to merge tiles. Coins based on highest tile reached. Unlimited replay value.
Memory Flip
Flip cards to find pairs. 4x4 grid. Coins scale with fewer moves and faster time.
Sliding Puzzle
Rearrange 8 tiles to form an image or number order. Daily new picture.
Math Blitz
Rapid arithmetic questions with 4 options. Streak multiplier. 60 seconds.
Word Scramble
Unscramble a word from a category. Hints cost coins, which creates a coin sink.
Word Search
Find 6 hidden words in a 8x8 grid. Drag to select. Timer based coins.
Mini Sudoku
4x4 or 6x6 boards so it fits a phone and a 2 minute session.
Tic Tac Toe vs AI
Simple minimax opponent with a difficulty slider. Coins only on win or draw.
Quiz and trivia
Cheapest to build and easiest to keep fresh, since content lives in the database, not in the app.
Daily Quiz
10 questions across GK, sports, movies, tech. One attempt per day, fixed reward per correct answer.
True or False Sprint
Rapid fire statements, 45 seconds, streak bonus every 5 correct.
Guess the Flag / Logo
Image based questions. Very shareable. Store images in Firebase Storage.
Emoji Riddle
Guess the movie or phrase from emojis. Type or pick from 4 options.
Predict and Win
Predict tomorrow's match result or a poll outcome. Winners split a coin pool. No coins are staked.
Daily reward games
These are login hooks, not skill games. Keep them free, once per day, and never let users stake coins. See store policies.
Spin the Wheel
One free spin per day. Extra spin by watching a rewarded ad. Weighted segments controlled from the server.
Scratch Card
Swipe to reveal a coin amount. Satisfying animation and sound. Three cards a day.
Daily Check-in
7 day calendar with rising rewards. Missing a day resets the streak. Biggest retention lever.
Mystery Box
Opens every 4 hours. Small random reward. Push notification when ready.
Arcade games (Flame engine)
Highest engagement and highest effort. Build with the Flame game engine, which gives you a game loop, sprites, collisions and input handling.
Fruit Slice
Swipe to cut fruits flying up the screen. Avoid bombs. Combo for multiple in one swipe.
Snake
Classic grid snake with swipe control. Easy with Flame or even plain widgets.
Brick Breaker
Paddle and ball. Drag paddle to keep the ball in play, clear all bricks.
Bubble Shooter
Aim and shoot bubbles, match 3 to pop. Deep replay, but collision logic takes time.
Endless Runner
Swipe left, right or up to dodge obstacles. Distance based score.
Tilt Maze
Tilt the phone to roll a ball through a maze using the accelerometer.
Recommended launch set
Ship 8 games at launch, covering every category so different user tastes are served. All are low or medium effort, so one developer can finish in roughly 4 to 6 weeks including wallet and backend.
| # | Game | Category | Effort | Why it is in the set |
|---|---|---|---|---|
| 1 | Daily Check-in | Daily reward | Low | Retention backbone, drives streak multiplier |
| 2 | Spin the Wheel | Daily reward | Low | Daily hook, natural rewarded ad placement |
| 3 | Scratch Card | Daily reward | Low | Tactile and satisfying, second daily hook |
| 4 | Daily Quiz | Trivia | Low | Content refreshed from server without app updates |
| 5 | Colour Match | Reflex | Low | Highly addictive, one-hand play |
| 6 | Whack-a-Mole | Reflex | Low | Universally understood, fun for all ages |
| 7 | Memory Flip | Puzzle | Low | Calm alternative to reflex games |
| 8 | 2048 | Puzzle | Medium | Long sessions, strong score chasing |
Add Snake, Fruit Slice and Word Search in the first update after launch, once you see which category users play most.
Coin economy
Reward formula
Every game returns a raw score. The server converts it to coins using a formula per game, so you can tune rewards without an app update.
coins = min(cap, base + floor(score / divisor)) × streakMultiplier
| Game | Base | Divisor | Cap per play | Plays per day | Max per day |
|---|---|---|---|---|---|
| Colour Match | 5 | 10 | 25 | 5 | 125 |
| Whack-a-Mole | 5 | 8 | 25 | 5 | 125 |
| Memory Flip | 10 | n/a | 30 | 3 | 90 |
| 2048 | 5 | 200 | 40 | 3 | 120 |
| Daily Quiz | 0 | 1 correct = 5 | 50 | 1 | 50 |
| Spin the Wheel | weighted 5 to 100 | n/a | 100 | 1 free + 2 ad | ~60 avg |
| Scratch Card | weighted 5 to 50 | n/a | 50 | 3 | ~45 avg |
| Daily Check-in | 10 to 70 by streak day | n/a | 70 | 1 | 70 |
With these numbers, a fully engaged user earns roughly 600 to 700 coins per day. Set your offer prices so that a small reward (for example a ₹10 mobile recharge) costs around 5,000 coins, meaning about a week of daily play. Adjust to your actual ad revenue per user.
Streak multiplier
| Consecutive days | Multiplier |
|---|---|
| 1 to 2 | 1.0× |
| 3 to 6 | 1.25× |
| 7 to 13 | 1.5× |
| 14+ | 2.0× |
Coin sinks
Give users things to spend on besides redemption. It makes the economy healthier and increases engagement.
- Hints in Word Scramble and Sudoku (5 coins).
- "Continue after game over" in arcade games (20 coins).
- Extra life in Whack-a-Mole (10 coins).
- Cosmetic themes and avatars (200 to 500 coins).
Missions
- Daily: Play 3 different games (+30), Score 500 in any game (+20), Watch 2 rewarded ads (+20).
- Weekly: Play 7 days in a row (+150), Reach 2048 tile once (+100).
- Weekly leaderboard: top 10 per game share a bonus pool of 2,000 coins.
Wallet and redemption
Wallet screen
- Balance card: big number, "coins earned today", streak indicator.
- Transactions tab: list with type (Earned, Spent, Redeemed, Bonus, Reversed), game name, amount, timestamp.
- Offers tab: grid of redeemable offers, each with image, title, coin cost, stock left, and a Redeem button that is disabled when balance is insufficient.
- My Redemptions tab: status timeline (Pending → Approved → Delivered, or Rejected with coins refunded).
Offer types you can provide
- Mobile recharge or data packs.
- Gift vouchers (Amazon, Flipkart, Google Play).
- Discount coupon codes from partner brands.
- UPI cash out (requires KYC and payout integration, treat as a later phase).
- In-app perks: premium themes, ad-free day.
Architecture
- Frontend
- Flutter 3.x, Riverpod for state, go_router for navigation, Flame for arcade games
- Backend
- Firebase: Auth, Cloud Firestore, Cloud Functions (Node.js or TypeScript), Cloud Messaging, Remote Config, App Check
- Ads
- Google AdMob rewarded video and interstitials via google_mobile_ads
- Admin panel
- Flutter Web or a simple React page, reading the same Firestore, deployed on Cloudflare Pages
- Analytics
- Firebase Analytics and Crashlytics
Request flow for one game session
Client asks to start
App calls Cloud FunctionstartSession(gameId). Server checks daily play count, creates a session document with a random ID and start timestamp, returns the session ID.User plays
The game runs entirely on the device. No network needed during play.Client submits result
App callssubmitSession(sessionId, score, meta). Meta includes things like taps count or moves, used for sanity checks.Server validates
Session exists, not already submitted, elapsed time is plausible for that score, score under the game's hard maximum. Then applies the reward formula and streak multiplier.Server writes atomically
In one Firestore transaction: mark session submitted, add a transaction record, increment wallet balance, update daily stats and missions.Client shows result
Response includes coins earned and the new balance. Result screen offers "Watch ad to double", which callsclaimAdBonus(sessionId)after the AdMob server-side verification callback fires.
wallets and transactions collections read-only for clients.Data model (Firestore)
users/{uid}
displayName, photoUrl, createdAt, streakDays, lastCheckIn, fcmToken, referralCode
wallets/{uid}
balance: int, lifetimeEarned: int, lifetimeSpent: int, updatedAt
transactions/{txId}
uid, type: "earn" | "spend" | "redeem" | "bonus" | "reversal"
amount: int, gameId?, sessionId?, offerId?, note, createdAt
games/{gameId}
name, category, enabled, order, iconUrl
reward: { base, divisor, cap, playsPerDay, maxScore, minSecondsPerPoint }
sessions/{sessionId}
uid, gameId, startedAt, submittedAt?, score?, coins?, status: "open" | "submitted" | "rejected"
dailyStats/{uid}_{yyyymmdd}
plays: { gameId: count }, coinsEarned, adsWatched, missions: { missionId: progress }
offers/{offerId}
title, description, imageUrl, cost, stock, type, enabled, createdAt
redemptions/{redemptionId}
uid, offerId, cost, status: "pending" | "approved" | "delivered" | "rejected"
code?, adminNote?, createdAt, updatedAt
quizQuestions/{qid}
category, question, options[4], answerIndex, difficulty, activeDate?
leaderboards/{gameId}_{yyyyww}/entries/{uid}
displayName, bestScore, updatedAt
Firestore security rules (essentials)
rules_version = '2';
service cloud.firestore {
match /databases/{db}/documents {
function signedIn() { return request.auth != null; }
function isOwner(uid) { return signedIn() && request.auth.uid == uid; }
match /users/{uid} {
allow read: if isOwner(uid);
allow update: if isOwner(uid)
&& request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['displayName', 'photoUrl', 'fcmToken']);
allow create: if isOwner(uid);
}
// Wallet and money-like collections: read only for clients.
match /wallets/{uid} { allow read: if isOwner(uid); allow write: if false; }
match /transactions/{id} { allow read: if signedIn() && resource.data.uid == request.auth.uid; allow write: if false; }
match /sessions/{id} { allow read: if signedIn() && resource.data.uid == request.auth.uid; allow write: if false; }
match /redemptions/{id} { allow read: if signedIn() && resource.data.uid == request.auth.uid; allow write: if false; }
match /dailyStats/{id} { allow read: if signedIn() && id.matches(request.auth.uid + '_.*'); allow write: if false; }
// Public catalogue data
match /games/{id} { allow read: if signedIn(); allow write: if false; }
match /offers/{id} { allow read: if signedIn(); allow write: if false; }
match /quizQuestions/{id} { allow read: if signedIn(); allow write: if false; }
match /leaderboards/{lb}/entries/{uid} { allow read: if signedIn(); allow write: if false; }
}
}
Step 1. Project setup
Install tools
Flutter SDK 3.22 or newer, Android Studio with SDK 34, Xcode 15 or newer on macOS, Node.js 18 or newer for Cloud Functions, and the Firebase CLI.flutter --version node --version npm install -g firebase-tools dart pub global activate flutterfire_cli
Create the Flutter project
cd ~/flutter_projects flutter create --org com.earno --project-name earno_plaza earno_plaza cd earno_plaza
Add dependencies
flutter pub add flutter_riverpod go_router firebase_core firebase_auth cloud_firestore \ cloud_functions firebase_messaging firebase_remote_config firebase_analytics \ google_sign_in google_mobile_ads flame flutter_fortune_wheel scratcher \ audioplayers confetti shared_preferences intl cached_network_image \ flutter_animate sensors_plus flutter pub add --dev flutter_lints mocktail
Set minimum platform versions
Inandroid/app/build.gradlesetminSdk = 23. Inios/Podfilesetplatform :ios, '13.0'. Runflutter runonce to confirm the empty app builds on a device or emulator.
Step 2. Folder structure
Feature-first layout. Each game is a self-contained folder that implements one interface, so adding a game never touches wallet code.
Step 3. Firebase backend
Create a Firebase project
Go to console.firebase.google.com, create a project namedearno-plaza, enable Google Analytics.Connect Flutter
firebase login flutterfire configure --project=earno-plaza
This generateslib/firebase_options.dartand registers Android and iOS apps. Initialise inmain.dart:void main() async { WidgetsFlutterBinding.ensureInitialized(); await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); await MobileAds.instance.initialize(); runApp(const ProviderScope(child: EarnoApp())); }Enable services
In the console enable Authentication (Google, Phone), Firestore (production mode, choose a region close to your users such as asia-south1), Cloud Functions (requires Blaze plan, still free within generous limits), Cloud Messaging, Remote Config and App Check.Initialise Cloud Functions
firebase init functions # choose TypeScript, ESLint yes cd functions && npm install
Write the reward formula
functions/src/rewards.ts:export interface RewardConfig { base: number; divisor: number; cap: number; playsPerDay: number; maxScore: number; minSecondsPerPoint: number; } export function streakMultiplier(days: number): number { if (days >= 14) return 2.0; if (days >= 7) return 1.5; if (days >= 3) return 1.25; return 1.0; } export function coinsForScore(cfg: RewardConfig, score: number, streak: number): number { const raw = cfg.base + Math.floor(score / Math.max(cfg.divisor, 1)); const capped = Math.min(cfg.cap, raw); return Math.round(capped * streakMultiplier(streak)); }Write session functions
functions/src/sessions.ts:import * as admin from "firebase-admin"; import { onCall, HttpsError } from "firebase-functions/v2/https"; import { coinsForScore, RewardConfig } from "./rewards"; const db = admin.firestore(); const today = () => new Date().toISOString().slice(0, 10).replace(/-/g, ""); export const startSession = onCall({ enforceAppCheck: true }, async (req) => { const uid = req.auth?.uid; if (!uid) throw new HttpsError("unauthenticated", "Sign in required"); const gameId = String(req.data.gameId); const gameSnap = await db.doc(`games/${gameId}`).get(); if (!gameSnap.exists || !gameSnap.data()?.enabled) throw new HttpsError("not-found", "Game unavailable"); const cfg = gameSnap.data()!.reward as RewardConfig; const statsRef = db.doc(`dailyStats/${uid}_${today()}`); const stats = (await statsRef.get()).data() ?? { plays: {} }; const played = stats.plays?.[gameId] ?? 0; const rewarded = played < cfg.playsPerDay; // still playable, just no coins const sessionRef = db.collection("sessions").doc(); await sessionRef.set({ uid, gameId, rewarded, status: "open", startedAt: admin.firestore.FieldValue.serverTimestamp(), }); return { sessionId: sessionRef.id, rewarded, playsLeft: Math.max(0, cfg.playsPerDay - played) }; }); export const submitSession = onCall({ enforceAppCheck: true }, async (req) => { const uid = req.auth?.uid; if (!uid) throw new HttpsError("unauthenticated", "Sign in required"); const { sessionId, score } = req.data as { sessionId: string; score: number }; if (!Number.isInteger(score) || score < 0) throw new HttpsError("invalid-argument", "Bad score"); return db.runTransaction(async (tx) => { const sessionRef = db.doc(`sessions/${sessionId}`); const session = (await tx.get(sessionRef)).data(); if (!session || session.uid !== uid) throw new HttpsError("not-found", "No session"); if (session.status !== "open") throw new HttpsError("failed-precondition", "Already submitted"); const cfg = (await tx.get(db.doc(`games/${session.gameId}`))).data()!.reward as RewardConfig; const elapsed = (Date.now() - session.startedAt.toMillis()) / 1000; // Plausibility checks if (score > cfg.maxScore || elapsed < score * cfg.minSecondsPerPoint) { tx.update(sessionRef, { status: "rejected", score, submittedAt: admin.firestore.FieldValue.serverTimestamp() }); throw new HttpsError("failed-precondition", "Score rejected"); } const userRef = db.doc(`users/${uid}`); const streak = (await tx.get(userRef)).data()?.streakDays ?? 0; const coins = session.rewarded ? coinsForScore(cfg, score, streak) : 0; const walletRef = db.doc(`wallets/${uid}`); const statsRef = db.doc(`dailyStats/${uid}_${today()}`); const txRef = db.collection("transactions").doc(); tx.update(sessionRef, { status: "submitted", score, coins, submittedAt: admin.firestore.FieldValue.serverTimestamp() }); if (coins > 0) { tx.set(walletRef, { balance: admin.firestore.FieldValue.increment(coins), lifetimeEarned: admin.firestore.FieldValue.increment(coins), updatedAt: admin.firestore.FieldValue.serverTimestamp(), }, { merge: true }); tx.set(txRef, { uid, type: "earn", amount: coins, gameId: session.gameId, sessionId, note: `Scored ${score}`, createdAt: admin.firestore.FieldValue.serverTimestamp() }); } tx.set(statsRef, { [`plays.${session.gameId}`]: admin.firestore.FieldValue.increment(1), coinsEarned: admin.firestore.FieldValue.increment(coins), }, { merge: true }); return { coins, score }; }); });Deploy
firebase deploy --only functions,firestore:rules
Seed the games collection
Add one document per game in the Firestore console, for example:games/colour_match { "name": "Colour Match", "category": "reflex", "enabled": true, "order": 1, "reward": { "base": 5, "divisor": 10, "cap": 25, "playsPerDay": 5, "maxScore": 400, "minSecondsPerPoint": 0.3 } }
Step 4. Authentication
Google Sign-In
Add the SHA-1 and SHA-256 of your debug and release keystores in Firebase project settings, download the updatedgoogle-services.json. On iOS add the reversed client ID URL scheme inInfo.plist.cd android && ./gradlew signingReport
Auth service
class AuthService { final _auth = FirebaseAuth.instance; Stream<User?> get userChanges => _auth.authStateChanges(); Future<UserCredential> signInWithGoogle() async { final googleUser = await GoogleSignIn().signIn(); if (googleUser == null) throw Exception('Cancelled'); final googleAuth = await googleUser.authentication; final credential = GoogleAuthProvider.credential( accessToken: googleAuth.accessToken, idToken: googleAuth.idToken); return _auth.signInWithCredential(credential); } }Create the user document on first sign in
Use a Cloud Function auth trigger so the client cannot forge the welcome bonus:export const onUserCreated = functions.auth.user().onCreate(async (user) => { const batch = db.batch(); batch.set(db.doc(`users/${user.uid}`), { displayName: user.displayName ?? "Player", photoUrl: user.photoURL ?? null, streakDays: 0, createdAt: now() }); batch.set(db.doc(`wallets/${user.uid}`), { balance: 50, lifetimeEarned: 50, lifetimeSpent: 0, updatedAt: now() }); batch.set(db.collection("transactions").doc(), { uid: user.uid, type: "bonus", amount: 50, note: "Welcome bonus", createdAt: now() }); await batch.commit(); });Route guard
In go_router, redirect to/loginwhen the auth stream has no user, and to/homeotherwise.
Step 5. Game plugin interface
Every game implements one abstract class. The home grid, the session controller and the result screen only talk to this interface, so adding game number 20 is as easy as game number 1.
// lib/games/mini_game.dart
abstract class MiniGame {
String get id; // matches Firestore games/{id}
String get title;
String get emoji;
String get howToPlay; // one paragraph shown on first launch
Duration? get roundLength; // null for untimed games
/// Build the playable widget. Call [onFinished] exactly once with the score.
Widget build(BuildContext context, {required void Function(int score) onFinished});
}
// lib/games/game_registry.dart
final gameRegistry = <MiniGame>[
ColourMatchGame(),
WhackAMoleGame(),
MemoryFlipGame(),
Game2048(),
DailyQuizGame(),
];
Session controller
// lib/games/game_session_controller.dart
class GameSessionController extends StateNotifier<GameSessionState> {
GameSessionController(this._functions) : super(const GameSessionState.idle());
final FunctionsService _functions;
Future<void> start(String gameId) async {
state = const GameSessionState.starting();
final res = await _functions.call('startSession', {'gameId': gameId});
state = GameSessionState.playing(sessionId: res['sessionId'], rewarded: res['rewarded']);
}
Future<void> finish(int score) async {
final playing = state as _Playing;
state = const GameSessionState.submitting();
final res = await _functions.call('submitSession', {'sessionId': playing.sessionId, 'score': score});
state = GameSessionState.done(score: res['score'], coins: res['coins'], sessionId: playing.sessionId);
}
}
Game screen wrapper
One screen hosts any game. It starts the session, renders game.build(...), and navigates to the result screen when the game calls onFinished.
class GameScreen extends ConsumerWidget {
const GameScreen({required this.game});
final MiniGame game;
@override
Widget build(BuildContext context, WidgetRef ref) {
final session = ref.watch(gameSessionProvider);
return session.when(
idle: () { ref.read(gameSessionProvider.notifier).start(game.id); return const _Loader(); },
starting: () => const _Loader(),
playing: (_, rewarded) => Scaffold(
appBar: AppBar(title: Text(game.title), actions: [ if (!rewarded) const _NoCoinsChip() ]),
body: game.build(context, onFinished: (score) => ref.read(gameSessionProvider.notifier).finish(score)),
),
submitting: () => const _Loader(label: 'Saving your coins...'),
done: (score, coins, sessionId) => GameResultScreen(game: game, score: score, coins: coins, sessionId: sessionId),
);
}
}
Step 6. Build the first game: Colour Match
The Stroop game is the best first game. It is 150 lines, needs no engine, and proves the whole pipeline from session start to wallet credit.
class ColourMatchGame extends MiniGame {
@override String get id => 'colour_match';
@override String get title => 'Colour Match';
@override String get emoji => '🎨';
@override String get howToPlay => 'Tap the button that matches the COLOUR of the text, not the word. You have 60 seconds.';
@override Duration? get roundLength => const Duration(seconds: 60);
@override
Widget build(BuildContext context, {required void Function(int score) onFinished}) =>
_ColourMatchBoard(onFinished: onFinished, roundLength: roundLength!);
}
class _ColourMatchBoard extends StatefulWidget { /* ... */ }
class _ColourMatchBoardState extends State<_ColourMatchBoard> {
static const _colours = {
'RED': Colors.red, 'BLUE': Colors.blue, 'GREEN': Colors.green, 'YELLOW': Colors.amber,
};
final _rng = Random();
late String _word;
late MaterialColor _ink;
int _score = 0, _streak = 0, _secondsLeft = 60;
Timer? _timer;
@override
void initState() {
super.initState();
_next();
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
if (--_secondsLeft <= 0) { _timer?.cancel(); widget.onFinished(_score); }
setState(() {});
});
}
void _next() {
final keys = _colours.keys.toList()..shuffle(_rng);
_word = keys.first;
_ink = _colours[keys[_rng.nextBool() ? 0 : 1]]!; // 50% match, 50% mismatch
}
void _tap(String choice) {
final correct = _colours[choice] == _ink;
setState(() {
if (correct) { _streak++; _score += 10 + (_streak >= 5 ? 5 : 0); }
else { _streak = 0; _score = max(0, _score - 5); }
_next();
});
HapticFeedback.lightImpact();
}
@override
void dispose() { _timer?.cancel(); super.dispose(); }
@override
Widget build(BuildContext context) => Column(children: [
_Hud(score: _score, seconds: _secondsLeft, streak: _streak),
Expanded(child: Center(child: Text(_word,
style: TextStyle(fontSize: 64, fontWeight: FontWeight.w900, color: _ink)))),
GridView.count(crossAxisCount: 2, shrinkWrap: true, childAspectRatio: 2.2,
padding: const EdgeInsets.all(16), mainAxisSpacing: 12, crossAxisSpacing: 12,
children: [ for (final e in _colours.entries)
ElevatedButton(style: ElevatedButton.styleFrom(backgroundColor: e.value),
onPressed: () => _tap(e.key), child: Text(e.key, style: const TextStyle(color: Colors.white))) ]),
]);
}
sessions with status submitted, a matching transactions entry, and the wallet balance increased. Everything after this is repetition.Step 7. Reward engine and daily games
Daily check-in function
ComparelastCheckInwith today. If it was yesterday, increment streak. If older, reset to 1. Reward by streak day: 10, 15, 20, 30, 40, 50, 70 then repeat from day 7 value.Spin the wheel function
Server picks the segment using weighted random (for example 5 coins at 40%, 10 at 30%, 20 at 15%, 50 at 10%, 100 at 5%), credits the wallet, and returns the segment index. The client animates the wheel to land on that index. The client never picks the result.Scratch card function
Same idea:getScratchCard()returns a pre-decided amount and a card ID. Coins are credited when the user callsrevealScratchCard(cardId), which also enforces the 3 per day limit.Remote Config
Put every tunable number (plays per day, base rewards, multipliers) in Remote Config or the games collection, so you can rebalance the economy without a release.
Step 8. Wallet UI
Wallet provider
A RiverpodStreamProvideronwallets/{uid}so the balance updates live everywhere in the app.final walletProvider = StreamProvider<Wallet>((ref) { final uid = ref.watch(authProvider).value!.uid; return FirebaseFirestore.instance.doc('wallets/$uid').snapshots() .map((s) => Wallet.fromMap(s.data() ?? {})); });Balance card
Large coin count with a counting animation when it changes (useflutter_animateorTweenAnimationBuilder). Show streak days and "earned today".Transactions list
Paginated query ontransactionswhereuid == meordered bycreatedAt desc, 20 per page. Colour credits green and debits red.Offers grid
Queryofferswhereenabled == true. Card shows image, title, cost, "Only 5 left" when stock is low. Redeem button disabled when balance is lower than cost.Result screen coin animation
After a game, animate coins flying into the wallet icon withconfettiand a coin sound. This small touch dramatically increases perceived reward.
Step 9. Redemption flow
Cloud Function redeemOffer
In a transaction: read offer, check enabled and stock greater than 0, read wallet, check balance at least cost, then decrement stock, decrement balance, write a transaction of type redeem, and create a redemption document with status pending.Client confirmation sheet
Show offer, cost, balance after redemption, and a checkbox "I understand delivery may take up to 48 hours". Then call the function and navigate to My Redemptions.Admin panel
A simple Flutter Web page (or the Firestore console at first) listing pending redemptions. Approve with a coupon code, or reject with a reason. A Firestore trigger onredemptionsstatus change sends a push notification and, on reject, writes a reversal transaction refunding the coins.Fraud check before approval
Show the admin the user's account age, total plays, ad watch count and rejected sessions. New accounts with very high earnings and zero ads watched are a red flag.
Step 10. Rewarded ads
AdMob setup
Create an AdMob account, add the Android and iOS apps, create one Rewarded ad unit and one Interstitial ad unit per platform. Add the App IDs toAndroidManifest.xmlandInfo.plist. Use Google's test ad unit IDs during development.Enable server-side verification (SSV)
In the rewarded ad unit settings turn on SSV and set the callback URL to an HTTPS Cloud Function, for examplehttps://asia-south1-earno-plaza.cloudfunctions.net/admobSsv. AdMob will call it when the user finishes an ad. Verify the signature using Google's public keys, then credit coins. This is the only way to make ad rewards uncheatable.Pass custom data
When loading the ad, setServerSideVerificationOptions(userId: uid, customData: sessionId)so the callback knows which session to double.Placements
Result screen "Double your coins", Spin the Wheel "Extra spin", Mystery Box "Open now instead of waiting". Show one interstitial after every third game, never during a game.Preload
Load the next rewarded ad as soon as a game starts so it is ready by the result screen.
Step 11. Anti-cheat
Play-to-earn apps are targeted by scripts and modified APKs within days of launch. Do these from day one.
- Server-side everything: sessions, scores, coins, spins, scratch results and ad rewards are all decided on the server.
- Firebase App Check with Play Integrity on Android and App Attest on iOS. Set
enforceAppCheck: trueon every callable function. Rejects requests from modified apps and emulators. - Plausibility rules: max score per game, minimum time per point, minimum session duration, maximum sessions per hour.
- One session at a time: reject
startSessionif the user already has an open session younger than 10 minutes. - Device fingerprint: store a hashed device ID per user. Flag accounts sharing a device.
- Redemption gates: minimum account age of 3 days, minimum 20 sessions and phone verification before the first redemption.
- Rate limits and alerts: a scheduled function flags any user earning more than the theoretical daily maximum, and auto-freezes redemptions for manual review.
- Obfuscate the release build:
flutter build apk --obfuscate --split-debug-info=build/symbols.
Step 12. Retention features
- Push notifications via Firebase Cloud Messaging: daily spin ready (09:00 local), streak about to break (20:00 if not checked in), redemption delivered, weekly leaderboard results.
- Missions tab with daily and weekly goals and progress bars. Claiming a mission reward is a callable function.
- Weekly leaderboard per game. A scheduled function runs every Monday 00:00, pays out the top 10 and archives the board.
- Referral: each user has a code. When a referred user completes 10 sessions, both get 100 coins. Cap referrals per user per month.
- Sound and haptics on every tap, win and coin credit. Provide a mute toggle.
- Featured game rotation on the home screen, driven by Remote Config, with a 2× coin banner for the featured game on weekends.
Step 13. Testing and release
Unit test the reward formula
TestcoinsForScoreandstreakMultiplierinfunctions/srcwith Jest, and any pure Dart game logic (2048 merge, memory pair matching) withflutter test.Firebase emulator
firebase emulators:startruns Auth, Firestore and Functions locally. Point the app at the emulators in debug builds so you can test without touching production data.Manual test matrix
Each game on a low-end Android device (2 GB RAM), an iPhone, offline mode mid-game, app backgrounded mid-game, double tap on submit, and time changed on the device.Beta
Google Play internal testing and TestFlight with 20 to 50 users for two weeks. Watch Crashlytics, average coins per user per day, and ad fill rate.Release build
flutter build appbundle --release --obfuscate --split-debug-info=build/symbols flutter build ipa --release --obfuscate --split-debug-info=build/symbols
Store listing
Screenshots of 4 games and the wallet, a 20 second video, privacy policy URL (required), and clear wording that coins have no cash value and rewards are subject to availability.
Package reference
| Package | Purpose |
|---|---|
flutter_riverpod | State management and dependency injection |
go_router | Declarative navigation with auth redirects |
firebase_core, firebase_auth, cloud_firestore, cloud_functions | Backend |
firebase_messaging, firebase_remote_config, firebase_analytics, firebase_crashlytics, firebase_app_check | Push, config, analytics, crashes, integrity |
google_sign_in | Google login |
google_mobile_ads | AdMob rewarded and interstitial ads |
flame | 2D game engine for arcade games |
flutter_fortune_wheel | Spin the wheel widget |
scratcher | Scratch card widget |
confetti, flutter_animate | Celebration and reward animations |
audioplayers | Sound effects |
sensors_plus | Accelerometer for tilt games |
cached_network_image | Offer and quiz images |
shared_preferences | Local settings such as mute, seen tutorials |
intl | Number and date formatting |
Store policy essentials
- Users must never pay or stake coins for a chance outcome. Spin and scratch are free daily bonuses only.
- Do not promise cash. Describe rewards as "coins redeemable for offers, subject to availability". Include Terms and a Privacy Policy in the app and on the store listing.
- Avoid the words "win money", "cash", "lottery" and "betting" in the listing and in the app.
- Rewarded ads must be clearly optional. Never block gameplay behind an ad.
- If you offer UPI or cash payouts later, you will need KYC, tax handling and possibly a legal review for your jurisdiction. Start with vouchers and recharges.
- Set the app's Play Console "Ads" declaration to yes and complete the Data Safety form accurately.
- For iOS, Guideline 3.2.2 and 5.3 apply. Sweepstake-like features need clear rules and no purchase requirement.
Roadmap
| Phase | Duration | Deliverables |
|---|---|---|
| 1. Foundation | Week 1 to 2 | Project setup, Firebase, auth, wallet provider, game interface, session functions, Colour Match end to end |
| 2. Launch games | Week 3 to 4 | Check-in, Spin, Scratch, Daily Quiz, Whack-a-Mole, Memory Flip, 2048 |
| 3. Wallet and offers | Week 5 | Transactions, offers grid, redemption function, admin approvals, push notifications |
| 4. Monetise and secure | Week 6 | AdMob with SSV, App Check, plausibility rules, redemption gates |
| 5. Beta | Week 7 to 8 | Internal testing, economy tuning, crash fixes, store listing |
| 6. Post launch | Ongoing | Missions, leaderboards, referrals, Snake, Fruit Slice, Word Search, cosmetics shop |
Hosting these docs on Cloudflare Pages
This page is a single static HTML file, which makes Cloudflare Pages a perfect free host. Two ways to deploy.
Option A: Wrangler CLI (fastest)
Install Node.js
Version 18 or newer from nodejs.org. Wrangler runs throughnpx, so no global install is needed.Log in to Cloudflare
npx wrangler login
A browser tab opens. Approve the permissions and return to the terminal.Put the HTML in a folder
Cloudflare deploys a directory, not a file. The file must be namedindex.htmlto load at the root URL.docs/ └── index.html
Create the project
npx wrangler pages project create earno-plaza-docs --production-branch main
Deploy
npx wrangler pages deploy docs --project-name earno-plaza-docs --branch main
Wrangler uploads the folder and prints the live URL, for examplehttps://earno-plaza-docs.pages.dev. Re-run the same command whenever you edit the file. Each deploy is versioned and you can roll back in the dashboard.Optional: custom domain
Dashboard → Workers & Pages → earno-plaza-docs → Custom domains → Set up a domain. If the domain is on Cloudflare DNS the record is created for you, otherwise add the CNAME shown.
Option B: Git integration (auto deploy on push)
Push the folder to GitHub
git init git add docs git commit -m "Add Earno Plaza docs" git branch -M main git remote add origin https://github.com/YOUR_USER/earno-plaza.git git push -u origin main
Connect in the dashboard
Cloudflare dashboard → Workers & Pages → Create → Pages → Connect to Git → pick the repo.Build settings
Framework preset: None. Build command: leave empty. Build output directory:docs. Save and Deploy.Done
Every push tomaindeploys to production. Every other branch gets a preview URL.
Useful commands
# list projects npx wrangler pages project list # list deployments of this project npx wrangler pages deployment list --project-name earno-plaza-docs # delete the project npx wrangler pages project delete earno-plaza-docs