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.

Flutter 3.xFirebaseFlame engineAdMobAndroid + iOS

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

  1. 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.
  2. 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.
  3. 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.
  4. Wallet

    Balance, transaction history (earned, spent, pending), and the Offers tab listing redeemable rewards.
  5. Redeem

    Pick an offer → confirm → coins deducted → offer status shows Pending → you fulfil it → status becomes Delivered with the coupon code or details.
  6. 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.

LowWidgets
🎨

Colour Match

The word "RED" is shown in blue text. Tap the button matching the ink colour, not the word. 60 second round.

LowWidgets
🔨

Whack-a-Mole

3x3 grid, moles pop up for shorter and shorter periods. Miss three and the round ends.

LowWidgets
🧱

Stack Blocks

A block slides side to side. Tap to drop it on the tower. Overhang is cut off. Perfect drops give bonus coins.

MediumCustomPainter
🐤

Flappy Jump

Tap to flap through gaps. Score is pipes passed. Classic, and everybody knows how to play.

MediumFlame

Reaction Time

Screen turns green after a random delay. Tap as fast as you can. Average of 5 tries gives the score.

LowWidgets

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.

MediumWidgets
🃏

Memory Flip

Flip cards to find pairs. 4x4 grid. Coins scale with fewer moves and faster time.

LowWidgets
🧩

Sliding Puzzle

Rearrange 8 tiles to form an image or number order. Daily new picture.

LowWidgets

Math Blitz

Rapid arithmetic questions with 4 options. Streak multiplier. 60 seconds.

LowWidgets
🔤

Word Scramble

Unscramble a word from a category. Hints cost coins, which creates a coin sink.

LowWidgets
🔍

Word Search

Find 6 hidden words in a 8x8 grid. Drag to select. Timer based coins.

MediumWidgets
9️⃣

Mini Sudoku

4x4 or 6x6 boards so it fits a phone and a 2 minute session.

MediumWidgets

Tic Tac Toe vs AI

Simple minimax opponent with a difficulty slider. Coins only on win or draw.

LowWidgets

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.

LowFirestore

True or False Sprint

Rapid fire statements, 45 seconds, streak bonus every 5 correct.

LowFirestore
🏳️

Guess the Flag / Logo

Image based questions. Very shareable. Store images in Firebase Storage.

LowStorage
😀

Emoji Riddle

Guess the movie or phrase from emojis. Type or pick from 4 options.

LowFirestore
🔮

Predict and Win

Predict tomorrow's match result or a poll outcome. Winners split a coin pool. No coins are staked.

MediumCloud Functions

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.

Lowflutter_fortune_wheel
🎫

Scratch Card

Swipe to reveal a coin amount. Satisfying animation and sound. Three cards a day.

Lowscratcher
📅

Daily Check-in

7 day calendar with rising rewards. Missing a day resets the streak. Biggest retention lever.

LowWidgets
🎁

Mystery Box

Opens every 4 hours. Small random reward. Push notification when ready.

LowWidgets

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.

HighFlame
🐍

Snake

Classic grid snake with swipe control. Easy with Flame or even plain widgets.

MediumFlame
🧱

Brick Breaker

Paddle and ball. Drag paddle to keep the ball in play, clear all bricks.

MediumFlame
🫧

Bubble Shooter

Aim and shoot bubbles, match 3 to pop. Deep replay, but collision logic takes time.

HighFlame
🏃

Endless Runner

Swipe left, right or up to dodge obstacles. Distance based score.

HighFlame
🎱

Tilt Maze

Tilt the phone to roll a ball through a maze using the accelerometer.

Mediumsensors_plus

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.

#GameCategoryEffortWhy it is in the set
1Daily Check-inDaily rewardLowRetention backbone, drives streak multiplier
2Spin the WheelDaily rewardLowDaily hook, natural rewarded ad placement
3Scratch CardDaily rewardLowTactile and satisfying, second daily hook
4Daily QuizTriviaLowContent refreshed from server without app updates
5Colour MatchReflexLowHighly addictive, one-hand play
6Whack-a-MoleReflexLowUniversally understood, fun for all ages
7Memory FlipPuzzleLowCalm alternative to reflex games
82048PuzzleMediumLong 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

The single most important ruleCoins you issue are a liability. Every coin must be backed by revenue (ads, partner offers, sponsorships). Cap issuance so the maximum any user can earn per day is known and affordable.

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
GameBaseDivisorCap per playPlays per dayMax per day
Colour Match510255125
Whack-a-Mole58255125
Memory Flip10n/a30390
20485200403120
Daily Quiz01 correct = 550150
Spin the Wheelweighted 5 to 100n/a1001 free + 2 ad~60 avg
Scratch Cardweighted 5 to 50n/a503~45 avg
Daily Check-in10 to 70 by streak dayn/a70170

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 daysMultiplier
1 to 21.0×
3 to 61.25×
7 to 131.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.
Manual fulfilment firstAt launch, fulfil redemptions by hand from an admin panel. You approve, paste the coupon code, and the user is notified. Automate with a voucher API later once volume justifies it.

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

  1. Client asks to start

    App calls Cloud Function startSession(gameId). Server checks daily play count, creates a session document with a random ID and start timestamp, returns the session ID.
  2. User plays

    The game runs entirely on the device. No network needed during play.
  3. Client submits result

    App calls submitSession(sessionId, score, meta). Meta includes things like taps count or moves, used for sanity checks.
  4. 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.
  5. Server writes atomically

    In one Firestore transaction: mark session submitted, add a transaction record, increment wallet balance, update daily stats and missions.
  6. Client shows result

    Response includes coins earned and the new balance. Result screen offers "Watch ad to double", which calls claimAdBonus(sessionId) after the AdMob server-side verification callback fires.
Never do thisNever let the app write to the wallet balance directly, and never compute coins on the device. Firestore rules must make the 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

  1. 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
  2. Create the Flutter project

    cd ~/flutter_projects
    flutter create --org com.earno --project-name earno_plaza earno_plaza
    cd earno_plaza
  3. 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
  4. Set minimum platform versions

    In android/app/build.gradle set minSdk = 23. In ios/Podfile set platform :ios, '13.0'. Run flutter run once 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.

lib/ ├── main.dart ├── app.dart # MaterialApp.router, theme ├── core/ │ ├── router.dart # go_router routes │ ├── theme.dart │ ├── constants.dart │ └── utils/ ├── services/ │ ├── auth_service.dart │ ├── firestore_service.dart │ ├── functions_service.dart # wraps callable Cloud Functions │ ├── ads_service.dart │ ├── audio_service.dart │ └── notifications_service.dart ├── features/ │ ├── onboarding/ │ ├── home/ │ ├── wallet/ │ │ ├── wallet_screen.dart │ │ ├── wallet_provider.dart │ │ ├── transactions_tab.dart │ │ ├── offers_tab.dart │ │ └── redemptions_tab.dart │ ├── missions/ │ ├── leaderboard/ │ └── profile/ └── games/ ├── game_registry.dart # list of all games ├── mini_game.dart # abstract interface ├── game_session_controller.dart ├── game_result_screen.dart ├── colour_match/ │ ├── colour_match_game.dart │ └── colour_match_screen.dart ├── whack_a_mole/ ├── memory_flip/ ├── game_2048/ ├── daily_quiz/ ├── spin_wheel/ ├── scratch_card/ └── check_in/ functions/ # Firebase Cloud Functions (TypeScript) ├── src/ │ ├── index.ts │ ├── sessions.ts # startSession, submitSession, claimAdBonus │ ├── daily.ts # checkIn, spinWheel, scratchCard │ ├── redemptions.ts # redeemOffer, admin approve/reject │ ├── missions.ts │ ├── leaderboard.ts # scheduled weekly payout │ └── rewards.ts # reward formula └── package.json

Step 3. Firebase backend

  1. Create a Firebase project

    Go to console.firebase.google.com, create a project named earno-plaza, enable Google Analytics.
  2. Connect Flutter

    firebase login
    flutterfire configure --project=earno-plaza
    This generates lib/firebase_options.dart and registers Android and iOS apps. Initialise in main.dart:
    void main() async {
      WidgetsFlutterBinding.ensureInitialized();
      await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
      await MobileAds.instance.initialize();
      runApp(const ProviderScope(child: EarnoApp()));
    }
  3. 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.
  4. Initialise Cloud Functions

    firebase init functions   # choose TypeScript, ESLint yes
    cd functions && npm install
  5. 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));
    }
  6. 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 };
      });
    });
  7. Deploy

    firebase deploy --only functions,firestore:rules
  8. 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

  1. Google Sign-In

    Add the SHA-1 and SHA-256 of your debug and release keystores in Firebase project settings, download the updated google-services.json. On iOS add the reversed client ID URL scheme in Info.plist.
    cd android && ./gradlew signingReport
  2. 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);
      }
    }
  3. 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();
    });
  4. Route guard

    In go_router, redirect to /login when the auth stream has no user, and to /home otherwise.

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))) ]),
  ]);
}
CheckpointRun the app, play a round, and confirm a new document appears in 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

  1. Daily check-in function

    Compare lastCheckIn with 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.
  2. 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.
  3. Scratch card function

    Same idea: getScratchCard() returns a pre-decided amount and a card ID. Coins are credited when the user calls revealScratchCard(cardId), which also enforces the 3 per day limit.
  4. 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

  1. Wallet provider

    A Riverpod StreamProvider on wallets/{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() ?? {}));
    });
  2. Balance card

    Large coin count with a counting animation when it changes (use flutter_animate or TweenAnimationBuilder). Show streak days and "earned today".
  3. Transactions list

    Paginated query on transactions where uid == me ordered by createdAt desc, 20 per page. Colour credits green and debits red.
  4. Offers grid

    Query offers where enabled == true. Card shows image, title, cost, "Only 5 left" when stock is low. Redeem button disabled when balance is lower than cost.
  5. Result screen coin animation

    After a game, animate coins flying into the wallet icon with confetti and a coin sound. This small touch dramatically increases perceived reward.

Step 9. Redemption flow

  1. 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.
  2. 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.
  3. 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 on redemptions status change sends a push notification and, on reject, writes a reversal transaction refunding the coins.
  4. 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

  1. 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 to AndroidManifest.xml and Info.plist. Use Google's test ad unit IDs during development.
  2. 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 example https://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.
  3. Pass custom data

    When loading the ad, set ServerSideVerificationOptions(userId: uid, customData: sessionId) so the callback knows which session to double.
  4. 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.
  5. 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: true on 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 startSession if 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

  1. Unit test the reward formula

    Test coinsForScore and streakMultiplier in functions/src with Jest, and any pure Dart game logic (2048 merge, memory pair matching) with flutter test.
  2. Firebase emulator

    firebase emulators:start runs Auth, Firestore and Functions locally. Point the app at the emulators in debug builds so you can test without touching production data.
  3. 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.
  4. 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.
  5. Release build

    flutter build appbundle --release --obfuscate --split-debug-info=build/symbols
    flutter build ipa --release --obfuscate --split-debug-info=build/symbols
  6. 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

PackagePurpose
flutter_riverpodState management and dependency injection
go_routerDeclarative navigation with auth redirects
firebase_core, firebase_auth, cloud_firestore, cloud_functionsBackend
firebase_messaging, firebase_remote_config, firebase_analytics, firebase_crashlytics, firebase_app_checkPush, config, analytics, crashes, integrity
google_sign_inGoogle login
google_mobile_adsAdMob rewarded and interstitial ads
flame2D game engine for arcade games
flutter_fortune_wheelSpin the wheel widget
scratcherScratch card widget
confetti, flutter_animateCelebration and reward animations
audioplayersSound effects
sensors_plusAccelerometer for tilt games
cached_network_imageOffer and quiz images
shared_preferencesLocal settings such as mute, seen tutorials
intlNumber and date formatting

Store policy essentials

Read before you build the luck gamesGoogle Play and the App Store treat "real money gambling" and misleading reward apps very seriously. Follow these to stay compliant.
  • 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

PhaseDurationDeliverables
1. FoundationWeek 1 to 2Project setup, Firebase, auth, wallet provider, game interface, session functions, Colour Match end to end
2. Launch gamesWeek 3 to 4Check-in, Spin, Scratch, Daily Quiz, Whack-a-Mole, Memory Flip, 2048
3. Wallet and offersWeek 5Transactions, offers grid, redemption function, admin approvals, push notifications
4. Monetise and secureWeek 6AdMob with SSV, App Check, plausibility rules, redemption gates
5. BetaWeek 7 to 8Internal testing, economy tuning, crash fixes, store listing
6. Post launchOngoingMissions, 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)

  1. Install Node.js

    Version 18 or newer from nodejs.org. Wrangler runs through npx, so no global install is needed.
  2. Log in to Cloudflare

    npx wrangler login
    A browser tab opens. Approve the permissions and return to the terminal.
  3. Put the HTML in a folder

    Cloudflare deploys a directory, not a file. The file must be named index.html to load at the root URL.
    docs/
    └── index.html
  4. Create the project

    npx wrangler pages project create earno-plaza-docs --production-branch main
  5. Deploy

    npx wrangler pages deploy docs --project-name earno-plaza-docs --branch main
    Wrangler uploads the folder and prints the live URL, for example https://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.
  6. 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)

  1. 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
  2. Connect in the dashboard

    Cloudflare dashboard → Workers & Pages → Create → Pages → Connect to Git → pick the repo.
  3. Build settings

    Framework preset: None. Build command: leave empty. Build output directory: docs. Save and Deploy.
  4. Done

    Every push to main deploys 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