Most game apps end the game session with a static score screen. You played, you scored 350 points, here’s your tally, tap “Home” to go back. The next thing the player does is close the app.

The post-game moment is the highest-intent moment in the entire app. The player just finished something. They’re engaged. They’re primed for the next action. If the only option is “go home,” you’ve wasted the best 5 seconds of their session.

We built a configurable sequence that chains from the end screen into rewards, celebrations, and a “play next” CTA — all driven by Remote Config, so we can change the flow without shipping an app update.

The FunnelService

The core idea: after a game ends, the app evaluates which funnel steps should run, and renders them in order. Each step is independent and BLoC-free — it receives plain data, not a BLoC reference.

session_funnel_orchestrator.dart
dart
class SessionFunnelOrchestrator {
  const SessionFunnelOrchestrator();

  Future<List<FunnelStep>> buildSteps(SessionFunnelInput input) async {
    final steps = <FunnelStep>[];

    final questResult = await _shouldShowQuest();
    final hasSpinAvailable = await _shouldShowSpin();

    if (questResult != null && questResult.hasProgress) {
      steps.add(FunnelStep(FunnelStepType.questProgress, payload: questResult));
    }

    if (streakChanged || currentStreak > 0) {
      steps.add(FunnelStep(FunnelStepType.streakUpdate));
    }

    if (hasSpinAvailable) {
      steps.add(FunnelStep(FunnelStepType.spinAvailable));
    }

    if (_shouldShowFeedback(input)) {
      steps.add(FunnelStep(FunnelStepType.feedbackSoft));
    }

    steps.add(FunnelStep(FunnelStepType.nextGameCta));
    return steps;
  }
}

The orchestrator is async — it fetches quest progress, checks the spin wheel, and evaluates streak state. Each step is only included if its condition is met. The “play next” CTA is always last.

BLoC independence: the key architecture decision

Originally, the end screen received the game’s BLoC as a parameter. The Trek end screen used TrekBloc to read the puzzle state. The Recall end screen used RecallBlocto read the game result. But the funnel doesn’t care about the game — it cares about theoutcome.

We refactored every end screen to accept plain argument objects instead of BLoC references:

tk_end_screen.dart
dart
// Before: end screen depends on TrekBloc
TkEndScreen({required TrekBloc bloc, ...})

// After: end screen depends on plain data
TkEndScreen({required TkEndArgs args, ...})

TkEndArgscontains the score, difficulty, elapsed time, and game result. The end screen doesn’t know or care about the BLoC. It just renders the data and fires telemetry.

This decoupling is critical because the funnel runs after the BLoC is disposed. When the player finishes Trek and navigates to the end screen, the Trek bloc might already be cleaned up. If the end screen tried to read context.read<TrekBloc>(), it would throw a Provider-not-found error because the BLoC was unregistered when the user left the game module.

A game module that can’t clean up after itself isn’t a module — it’s just code in a folder. And a game module that leaves its BLoC alive for the funnel to read isn’t truly modular.

Remote Config gating

The funnel is Remote Config-driven. Each step has a kill switch:

  • quest_v2_enabled — show quest progress step?
  • spin_v2_enabled — show the spin wheel?
  • session_funnel_enabled — show the funnel at all?
  • shop_v2_enabled — show the store CTA?

If session_funnel_enabledis false, every game’s end screen just shows the score and a home button — the pre-funnel behavior. No funnel code runs. This lets us A/B test the funnel and roll it back instantly if engagement metrics dip.

The funnel flow

A typical end-game flow looks like this:

  1. End screen — Shows score, XP earned, game result. Fires telemetry.
  2. Quest progress step — If the player has an active quest with progress, show it (e.g., “Win 3 games: 2/3”).
  3. Streak update step — If the player’s daily streak changed, celebrate it (e.g., “🔥 7-day streak!”).
  4. Spin wheel step — If the daily spin count is < 3, show the wheel.
  5. Feedback step — Soft prompt for a rating if the player just won.
  6. Play next CTA — “Play Again” or “Try Trek” with difficulty selection.

Each step renders, waits for the user’s action (or auto-advances after a timeout), and the next step slides in. The whole sequence is driven by PageView with automatic advancement.

The “play next” CTA as a cross-sell

The last step isn’t just “play the same game again.” If the player just finished a Trek puzzle, the CTA might suggest Recall (“Test your memory”) or Wordgrid (“Try today’s crossword”). The suggestions are weighted toward games the player hasn’t tried recently, encouraging cross-game engagement.

The CTA also doubles as an ad-opt-in point. The “Double XP” button shows a rewarded ad before the next game. If the player watches, they earn a boost. If they skip, they start the next game normally.

What I’d Tell My Past Self

  1. Decouple the funnel from the game BLoC. Plain args, not BLoC references. The funnel outlives the game module.
  2. Remote Config every step. Kill switches let you A/B test and roll back without shipping an app update.
  3. Always end with a CTA. The worst post-game experience is a dead end. Always give the player something to do next.
  4. The funnel runs after the BLoC is disposed. This is not a bug — it’s the correct architecture. Lazy DI boot means the BLoC is gone by the time the funnel renders.
  5. Cross-sell is more effective than re-sell. After 3 Trek sessions in a row, suggest Recall. After a loss, suggest an easier game. After a win, suggest a harder one.