Building Case Analyst · Part 5 of 8
A data-driven mission & narrative engine in Flutter
TL;DR — Every case in Case Analyst is plain JSON, not Dart. A MissionPackage is a list of MissionConfig objects loaded from assets/missions/*.json via the asset manifest, deserialized into immutable models. A single ScenarioManager Riverpod notifier interprets that data at runtime — scan targets, hotspots, branching dialogue steps, an evidence archive — while progress lives in SharedPreferences. The payoff: I write narrative without recompiling, and players can side-load their own cases.
Three in the morning, the writing is going well, and the last thing I want is to stop, edit a Dart file, and wait for a hot restart just to see whether a clue lands. Early in development that was exactly the loop: missions were hardcoded. Adding a suspect meant touching widget code. It did not scale past the second case.
So I tore it out. Now a mission is data — a JSON document the engine interprets — and the Dart side knows nothing about any specific story. This post walks through that model: how a case is described, how it loads, how branching and "coherent" cross-mission state work, and how the evidence archive persists. Everything here is from the shipping repo.
A mission is a document, not a class
The campaigns ship as JSON files under assets/missions/. Each file is one MissionPackage — a buyable/unlockable bundle that contains an ordered list of missions:
@immutable
class MissionPackage {
final String id;
final LocalizedString title;
final LocalizedString description;
final List<MissionConfig> missions;
final bool isFree;
final String? storeProductId;
final int sortOrder;
factory MissionPackage.fromJson(Map<String, dynamic> json) {
return MissionPackage(
id: json['id'] as String,
title: LocalizedString.fromJson(json['title']),
description: LocalizedString.fromJson(json['description']),
missions: (json['missions'] as List<dynamic>)
.map((e) => MissionConfig.fromJson(e as Map<String, dynamic>))
.toList(),
isFree: json['isFree'] as bool? ?? true,
storeProductId: json['storeProductId'] as String?,
sortOrder: json['sortOrder'] as int? ?? 0,
);
}
}
Notice LocalizedString everywhere a player sees text. Every title, terminal line, and dialogue choice is a tiny {"en": "...", "it": "..."} map, so localization is part of the data model rather than bolted on. The current build ships three packages — starter_pack (6 missions), ghost_protocol (8) and panopticon (7), 21 cases in total.
The interesting type is MissionConfig. It is wide because a detective case has a lot of moving parts, but every field is just deserialized config:
@immutable
class MissionConfig {
final String id;
final LocalizedString title;
final int timerSeconds;
final bool isTimedMission;
final String agentName;
final IntroVideoConfig? introVideo;
final List<String> initialCommands;
final List<HotspotConfig> hotspots; // clickable evidence in images
final List<ScanTarget> scanTargets; // terminal commands that pay off
final List<MissionStep> steps; // branching dialogue / triggers
final List<MissionObjective> objectives;
final List<ArchiveEntryConfig> initialArchiveEntries;
final BranchConfig? branchConfig; // story fork on a flag
// ...
}
That is the whole "engine surface": a case is some commands the player starts with, some scanTargets (a command + an argument that, when typed, advance the case), hotspots the player can find inside forensic images, and a graph of steps — the narrative. Nothing about this specific case exists in code.
Loading from the asset bundle
The loader is deliberately dumb. It asks Flutter's AssetManifest for everything under assets/missions/, parses each file, and skips anything that fails rather than crashing the app:
class MissionLoaderService {
Future<List<MissionPackage>> loadBundledPackages() async {
final manifest = await AssetManifest.loadFromAssetBundle(rootBundle);
final paths = manifest
.listAssets()
.where((p) => p.startsWith('assets/missions/') && p.endsWith('.json'))
.toList();
final packages = <MissionPackage>[];
for (final path in paths) {
try {
final jsonStr = await rootBundle.loadString(path);
final json = jsonDecode(jsonStr) as Map<String, dynamic>;
packages.add(MissionPackage.fromJson(json));
} catch (e) {
debugPrint('[MissionLoader] Failed to parse bundled $path: $e');
}
}
return packages;
}
}
Because the loader globs the manifest instead of importing a hardcoded list, adding a case is just dropping a JSON file in the folder. The same service also reads loadLocalPackages() from the app documents directory, which is how player-imported cases work — same parser, different source. A Riverpod FutureProvider merges the two and sorts by sortOrder:
final availablePackagesProvider =
FutureProvider<List<MissionPackage>>((ref) async {
final loader = ref.watch(missionLoaderProvider);
final bundled = await loader.loadBundledPackages();
final local = await loader.loadLocalPackages();
final all = [...bundled, ...local];
all.sort((a, b) => a.sortOrder.compareTo(b.sortOrder));
return all;
});
availablePackagesProvider rendered — bundled and side-loaded cases come through the same path.The runtime interpreter: ScenarioManager
If the JSON is the script, ScenarioManager is the director. It is a Riverpod Notifier<MissionState> that holds the live mission state — timer, trace %, evidence %, discovered hotspots, current dialogue step — and mutates it in response to events on a global EventBus:
class ScenarioManager extends Notifier<MissionState> {
@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));
// ...intro + audio/video-finished events
ref.onDispose(() { for (final s in subs) s.cancel(); });
return const MissionState();
}
}
The terminal does not know what any command "means" for the story. It just fires a CommandExecuted event, and the manager looks it up against the loaded data. When the player types something, _handleCommand tries to match it to a ScanTarget in the current mission:
void _handleCommand(CommandExecuted event) {
if (state.phase != MissionPhase.active || _config == null) return;
final command = event.command;
final target = event.args.join(' ').toUpperCase();
final match = _config!.scanTargets.where(
(s) => s.command == command && s.target.toUpperCase() == target,
);
if (match.isNotEmpty) {
final scan = match.first;
_applyOutcome(
evidenceGain: scan.evidenceGain,
traceDelta: scan.traceDelta,
unlockCommands: scan.unlockCommands,
);
_unlockArchiveEntries(scan.unlockArchiveEntries);
_fireForensicMedia(scan.loadForensicMedia);
if (scan.setFlag != null) {
state = state.copyWith(gameFlags: {...state.gameFlags, scan.setFlag!});
_progress.setFlag(scan.setFlag!); // persist the story flag
}
_checkScanTriggers(command, target);
return;
}
// wrong target -> trace penalty + glitch
}
A single matched scan target can: push evidence and trace meters, hand the player new commands, drop a forensic clip into the analyzer, add an entry to the archive, and set a persistent story flag. All of that is described in JSON. The Dart code is generic plumbing.
Branching without a scripting language
The narrative is a graph of MissionSteps. Each step has agent dialogue, optional player choices, and a trigger describing what fires it — a hotspot found, a command matched, all hotspots discovered, a video finished. Forking the story is handled by a deliberately small BranchConfig: a flag plus two outcomes.
bool _stepMatchesBranch(MissionStep step) {
final cond = step.conditionalOn;
if (cond == null) return true;
final branch = _config?.branchConfig;
if (branch == null) return true;
final flagSet = state.gameFlags.contains(branch.branchFlag);
if (cond == branch.branchA.id) return flagSet;
if (cond == branch.branchB.id) return !flagSet;
return true;
}
A step tagged conditionalOn: "branchA" only fires when the branch flag is set. On victory, the manager reads the same flag to decide which mission unlocks next and which one is permanently skipped:
if (endPhase == MissionPhase.victory && _config?.branchConfig != null) {
final branch = _config!.branchConfig!;
final flagSet = state.gameFlags.contains(branch.branchFlag);
final nextId = flagSet ? branch.branchA.setNextMission
: branch.branchB.setNextMission;
final skipped = flagSet ? branch.branchB.setNextMission
: branch.branchA.setNextMission;
state = state.copyWith(pendingNextMissionId: nextId);
_progress.markMissionSkipped(skipped);
}
I resisted building a real scripting DSL. For a detective game, a flag-driven fork plus per-step conditions covers the branches I actually write, and it stays debuggable. The whole branch model is two classes (BranchConfig, BranchOption) of about ten lines each.
Coherent state: starting at mission 4 without a save
The trickiest part wasn't authoring — it was letting a player jump into mission 4 of a pack and still have the right commands unlocked and the right evidence in their archive, even on a fresh install. I didn't want to bake "you start mission 4 with commands X, Y, Z" into each file by hand; it drifts the moment you edit an earlier mission.
Instead, MissionPackage can derive the coherent state by replaying the data of the prior missions. commandsUpTo(index) unions every command those missions would have unlocked; archiveEntriesUpTo(index) rebuilds the evidence set (minus anything those missions delete):
Set<String> commandsUpTo(int targetIndex) {
final cmds = <String>{};
for (var i = 0; i < targetIndex && i < missions.length; i++) {
final m = missions[i];
cmds.addAll(m.initialCommands);
for (final st in m.scanTargets) cmds.addAll(st.unlockCommands);
for (final step in m.steps) {
for (final outcome in step.choiceOutcomes.values) {
cmds.addAll(outcome.unlockCommands);
}
}
}
if (targetIndex < missions.length) {
cmds.addAll(missions[targetIndex].initialCommands);
}
return cmds;
}
The package-selection screen computes all three derived sets and seeds the runtime before launching the mission — note setCoherentEntries, which replaces the archive (marking entries as already-seen) rather than appending:
final coherentCommands = package.commandsUpTo(missionIndex);
final coherentArchive = package.archiveEntriesUpTo(missionIndex);
final coherentUpdates = package.archiveUpdatesUpTo(missionIndex);
ref.read(archiveProvider.notifier)
.setCoherentEntries(coherentArchive, updates: coherentUpdates);
ref.read(scenarioManagerProvider.notifier)
.loadMission(config, l10n, coherentCommands: coherentCommands);
The state you'd have earned is reconstructed from the same data that defines the missions. Edit mission 2 to unlock a new command and mission 4's starting state updates for free — no per-mission bookkeeping, no stale constants.
The archive: persisted evidence database
Clues the player uncovers land in the in-game archive — the case file of suspects, vehicles, documents and evidence. It's a Riverpod notifier over a List<ArchiveEntry>, persisted to SharedPreferences as JSON. Entries are deduped by id, can receive later updates (e.g. "this suspect now has a confirmed alias"), and updates targeting an entry that hasn't appeared yet are buffered and applied when it does:
void addEntries(List<ArchiveEntryConfig> configs) {
final existingIds = state.map((e) => e.id).toSet();
final newEntries = configs
.where((c) => !existingIds.contains(c.id))
.map((c) => ArchiveEntry(/* id, category, title, ... */))
.toList();
if (newEntries.isNotEmpty) {
state = [...state, ...newEntries];
_applyPendingUpdates(); // flush buffered updates
_persist(); // write JSON to SharedPreferences
}
}
ArchiveEntry rendered: title, subtitle, description and media — all from the JSON, with later updates stacked on.Progress itself is split across small, single-purpose services. MissionProgressService keeps completed/unlocked/skipped mission IDs and the global command + flag sets in SharedPreferences behind tidy string-list keys; the live, in-flight mission (timer, current step, discovered hotspots) is a separate MissionState that a save service can snapshot. Keeping "what I've permanently earned" apart from "where I am in this mission right now" made both the restore logic and the coherent-state replay much easier to reason about.
The unlock rule for the campaign list is one method — the first mission is always open, otherwise the previous one must be completed:
bool isMissionUnlocked(String missionId, List<String> ids, Set<String> done) {
final index = ids.indexOf(missionId);
if (index <= 0) return true; // first mission always unlocked
return done.contains(ids[index - 1]); // previous must be completed
}
Where AI fit in
The schema was hand-designed, but the cases themselves are a great fit for an LLM. Once the JSON contract was stable, I'd hand Claude the MissionConfig field list and a one-paragraph case premise, and have it draft the scan targets, hotspot reveal text and dialogue steps in both en and it. I still rewrite and pace by hand — an LLM will happily make a case too easy — but the boilerplate of 20 localized strings per mission is exactly the part worth automating. Because the loader skips files that fail to parse, a malformed AI draft is a non-event: I see one debugPrint and fix the JSON.
Takeaway
Pushing the entire campaign into data was the highest-leverage refactor in the project. The Dart side is a small, generic interpreter — a loader, an immutable model, one notifier that reacts to events — and it never changes when I write a new case. I get hot-reloadable narrative, free localization, player-importable mods, and coherent mid-campaign starts derived from the same source of truth. If you're building anything content-heavy in Flutter, the instinct to model content as data instead of code pays for itself by the second piece of content.
The series
- Building a detective game in pure Flutter (no game engine)
- An in-game terminal with a real command parser
- A forensic image analyzer: enhance, thermal & deepfake detection
- Field Link: a data-driven branching dialogue engine
- A data-driven mission & narrative engine in Flutter
- One codebase to iOS, Android, macOS, Windows & Steam
- The AI asset pipeline of a solo indie game
- 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, and follow for the next part.
Where to find Case Analyst
- Official site: case-analyst.com
- Steam (launches October 29 — wishlist now): store.steampowered.com
- iOS: App Store · Android: Google Play
- Follow: YouTube · Instagram · TikTok · Discord