Building Case Analyst · Part 8 of 8

Shipping a Flutter game solo: what worked and what I'd do differently

TL;DR — I built Case Analyst — a cyberpunk detective game — in pure Flutter, no game engine, part-time over roughly a year, and it ships on five platforms from one codebase (~20k lines of Dart). The pure-Flutter bet and the data-driven content pipeline paid off; Riverpod scaled but punished me for one early shortcut; cross-platform "free" wasn't free; AI tooling was a force multiplier with sharp edges; and marketing-as-a-dev was the hardest unsolved problem. Here's the honest ledger.

The last command has run. The save file is written, the build is signed, the Steam page says "Coming Soon." Across this series I've taken you through the terminal parser, the forensic analyzer, the dialogue engine, the mission format, the multi-platform packaging and the AI asset pipeline. This part is the receipt — what the architecture actually cost, and what I'd tell myself if I could send one message back to the first commit.

Case Analyst end-of-case screen showing a solved investigation summary in a cyberpunk terminal aesthetic
The payoff screen. Getting here took about a year of evenings and weekends.

The stack, recapped

For the developers arriving cold, here is the entire production stack — no engine, no native game framework:

21 missions across 3 mission packs, all defined in JSON (Part 5), running on iOS, Android, macOS, Windows and web from a single lib/ of about 20,000 lines.

What worked: the pure-Flutter bet

The riskiest decision was the first one: build a game with no game engine. No Flame, no Unity, no loop. The fear was that I'd hit a wall the moment I needed something "game-like" and have to bolt on a framework anyway.

That wall never came — because Case Analyst isn't a game of physics and sprites, it's a game of interfaces. A terminal, a chat, an image viewer, an archive. Flutter is a UI framework and this is, fundamentally, a UI. Animations that would've been a custom render pass in an engine were AnimatedContainer and TweenAnimationBuilder. The cyberpunk glitch effects were shaders and opacity tweens, not particle systems. Picking the tool that matched the shape of the problem, instead of the genre label, was the single best call I made.

It also meant I got hot reload for the entire game, including narrative content. Tweaking a mission's pacing was a save-and-see loop measured in seconds. No engine I know of gives you that for free.

What worked: data over code

The second thing I'd keep verbatim is that the content lives in data, not in Dart. Missions are JSON. Dialogue branches are JSON. The forensic targets are JSON. The code is a small set of interpreters; the game is the data they read.

Concretely: three pack files (starter_pack.json, panopticon.json, ghost_protocol.json) hold all 21 missions, with bilingual (en/it) strings baked into every node. Adding a case never meant touching the engine. That separation is what let a solo dev produce hours of narrative without the codebase collapsing under its own weight.

What worked (mostly): Riverpod at scale

Riverpod held up across ~60 Dart files and a dozen interacting providers. The pattern that aged best was decoupling features through a tiny event bus rather than wiring notifiers directly to each other:

class EventBus {
  final _controller = StreamController<GameEvent>.broadcast();

  void fire(GameEvent event) {
    if (kDebugMode) {
      debugPrint('[EventBus] $event');
    }
    _controller.add(event);
  }

  Stream<T> on<T extends GameEvent>() {
    return _controller.stream.where((e) => e is T).cast<T>();
  }

  void dispose() {
    _controller.close();
  }
}

final eventBusProvider = Provider<EventBus>((ref) {
  final bus = EventBus();
  ref.onDispose(() => bus.dispose());
  return bus;
});

The terminal doesn't know the mission engine exists. It just fires a CommandExecuted event. The ScenarioManager — itself a Riverpod Notifier — subscribes to the events it cares about in its build() and cleans them up via ref.onDispose:

class ScenarioManager extends Notifier<MissionState> {
  late EventBus _bus;

  @override
  MissionState build() {
    _bus = ref.watch(eventBusProvider);

    final subs = <StreamSubscription<dynamic>>[];
    subs.add(_bus.on<CommandExecuted>().listen(_handleCommand));
    subs.add(_bus.on<OptionSelected>().listen(_handleOption));
    subs.add(_bus.on<EvidenceDiscovered>().listen(_handleEvidence));
    // ... plus intro / forensic audio / forensic video events

    ref.onDispose(() {
      _timer?.cancel();
      _glitchTimer?.cancel();
      for (final sub in subs) {
        sub.cancel();
      }
    });

    return const MissionState();
  }
}

This is the lesson I'd hand to anyone building a stateful Flutter app of real size: let features publish facts, not call each other. The terminal, forensic analyzer and dialogue engine evolved independently for months precisely because none of them held a reference to the others.

The thing I'd do differently: I let a couple of Notifier classes grow into god-objects. The scenario manager handles commands, options, evidence, audio completion, video completion, timers and glitch timers. It works, it's well-tested by playthrough, but it's the one file I open with a sigh. Next time I'd split orchestration from the per-feature reaction handlers earlier, before the file crossed the "I have to scroll to understand it" threshold.

What cost more than expected: cross-platform

"Write once, run everywhere" is true, with an asterisk per platform. The asterisks add up.

The clearest scar is video. video_player is great on mobile and fine on macOS, but Windows needed the media_kit backend wired in (video_player_media_kit + media_kit_libs_windows_video) — a whole extra dependency stack just so the intro and forensic clips would play on the Steam build. None of that surfaced until I actually built for Windows.

The other one was monetization, and it shaped real architecture. Mobile is freemium with in-app purchases; Steam is a flat paid title with no IAP at all (in_app_purchase doesn't even have a Windows implementation). I solved it by hiding the whole concern behind an interface and selecting the implementation at runtime:

final purchaseServiceProvider = Provider<PurchaseService>((ref) {
  final PurchaseService service;
  if (kFreemiumMode && !kIsWeb && (Platform.isIOS || Platform.isAndroid)) {
    service = IapPurchaseService();
  } else {
    service = PurchaseServiceStub(); // Steam/desktop: everything "owned"
  }
  ref.onDispose(() => service.dispose());
  return service;
});

That PurchaseServiceStub — "all packs owned" — turned out to be one of the highest-leverage 30 lines in the project. It's the dev fallback, the desktop implementation, and the test seam. The takeaway: when a concern is genuinely different per platform, don't if/else it at every call site — define the interface, pick the impl once at the provider, and let the rest of the app stay platform-blind. Cross-platform was the most detailed part of the journey; I unpacked the full build matrix in Part 6.

Case Analyst running on a phone, showing the analyst desktop with terminal and tool panels adapted to a narrow screen
The same desktop on mobile. One codebase, but the responsive work was real labor, not a free lunch.

AI tooling: a force multiplier with sharp edges

I'm not going to be coy about this for a developer audience: AI was woven through the whole production. For assets — the case imagery, the forensic photos, the UI textures — it collapsed a budget I simply didn't have into something a solo dev could ship (the full pipeline is Part 7). For code, it was a very fast, very confident junior pair-programmer: excellent at boilerplate, JSON wrangling, and "translate this pattern across 12 files," and a liability the moment a subtle bit of game logic was on the line.

What I'd do differently is treat AI-written code with the same suspicion as a stranger's PR from the start. The places I got burned were the ones where I accepted a plausible-looking notifier change without playing through the consequence. The places it shone were the mechanical ones — and there were a lot of mechanical ones in a 20k-line codebase. Net: I'd take the bet again, with tighter review on anything touching state transitions.

What I underestimated: marketing as a dev

This is the honest, slightly uncomfortable part. The engineering was the comfortable half. Building the terminal parser was fun. Building an audience for a game about typing commands into a terminal is a different skill set entirely, and one I started far too late.

If I started over, marketing wouldn't be the thing I bolt on after the build compiles. The Steam page, the trailer, the screenshots, the wishlist funnel — those have lead times that dwarf a feature ticket, and they reward consistency over months, not a sprint at the end. I built a 30-day launch plan; I should have been building it in parallel with the forensic analyzer. A great game nobody knows about is a hobby, and I wanted this to be more than that.

The part-time-over-a-year reality

The thing the architecture diagrams never show: this was evenings and weekends, around other work, for roughly a year. That single constraint explains almost every good decision in this series. Data-driven content? Because I couldn't afford to recompile my brain into the engine every time I added a case. The event bus? Because I'd lose context for a week and needed features I could reason about in isolation when I came back. Pure Flutter? Because hot reload is the only thing that makes a 90-minute coding window productive.

The honest cost is that part-time means context loss is your biggest tax, not lines of code. I spent more time re-loading "how did this work again" than I'd like to admit. The fix wasn't more discipline — it was an architecture forgiving enough that re-loading was cheap. Every decoupling decision in this codebase is really a bet against my own future forgetfulness, and that's the bet that paid off most.

The takeaway

If you take one thing from eight parts: match your tools to the shape of your problem, not the label on it, and architect for the developer you'll be after you've forgotten everything. Pure Flutter wasn't the obvious choice for a "game," but it was the right choice for this game — an OS you investigate through. Riverpod plus an event bus plus JSON content gave a solo, part-time dev something genuinely rare: a codebase that stayed fun to come back to.

What I'd change is smaller and louder than the architecture: start marketing on day one, split your god-objects before they're gods, and review the AI's code like you review a stranger's. The detective work is done. The case is closed. Thanks for reading the file.

The series

  1. Building a detective game in pure Flutter (no game engine)
  2. An in-game terminal with a real command parser
  3. A forensic image analyzer: enhance, thermal & deepfake detection
  4. Field Link: a data-driven branching dialogue engine
  5. A data-driven mission & narrative engine (21 missions)
  6. One codebase to iOS, Android, macOS, Windows & Steam
  7. The AI asset pipeline of a solo indie game
  8. Shipping a Flutter game solo: what worked, what I'd change

Case Analyst is a cyberpunk detective game where you investigate by typing real commands in a terminal — out on mobile, coming to Steam on October 29. Wishlist it on Steam. Thanks for following the whole series.

Luca Panteghini — Nurale

{BEACON}