Back to blog
Devlog

Managing 32 Mini-Games with a Lazy Registry Pattern

brainfitflutterarchitectureperformance

When BrainFit had four or five mini-games, managing them was trivial. I instantiated each game widget where I needed it and moved on. By the time I hit 32 games across 6 cognitive areas, that naive approach had become genuinely painful — slow startup, shotgun surgery every time I added a game, and no single place that answered "what games does this app actually have?"

This post walks through the game registry pattern I built to fix all three problems at once.

The Problem: 32 Game Classes and a Measurably Slow Startup

Each mini-game in BrainFit has its own widget tree, rendering logic, and sometimes audio assets. If I eagerly instantiated all 32 game widgets and their controllers at boot, the app would take noticeably longer to reach the home screen. On lower-end Android devices — where minSdk 21 means we support hardware from several years back — I measured this. It was not a theoretical concern.

Beyond startup time, there was a structural problem. Every time I added a game, I had to touch at minimum four separate files: the routing table, the training screen's list, the daily mission generator, and the stats dashboard. Classic shotgun surgery. What I needed was a canonical declaration of "these 32 games exist" that every part of the app could read from.

The Solution: Separate Metadata from Runtime

The core insight is that the app needs to know about all 32 games at all times (to render the game list, generate missions, look up history records), but it only needs to run one game at a time, and only after the user explicitly taps it.

So I split the concept in two:

  • GameConfig — a compile-time constant describing a game's identity and parameters
  • Game widgets and GameController — the actual runtime, created on demand

GameConfig: A Compile-Time Constant

Here is the full GameConfig class from lib/features/games/engine/game_config.dart:

class GameConfig {
  final String id;
  final String nameKey;       // i18n key
  final String descriptionKey;
  final BrainArea area;
  final int timeLimitSeconds;
  final int initialLevel;
  final int maxLevel;
  final bool isPro;
  final String? evidenceKey;

  const GameConfig({
    required this.id,
    required this.nameKey,
    required this.descriptionKey,
    required this.area,
    this.timeLimitSeconds = 60,
    this.initialLevel = 1,
    this.maxLevel = 20,
    this.isPro = false,
    this.evidenceKey,
  });
}

Every field is either a primitive, a string, or a reference to a BrainArea enum value. The const constructor is the critical detail: every GameConfig is a compile-time constant, meaning the Dart compiler bakes it into the binary's data segment. No heap allocation at runtime. No initialization code that runs at startup.

A few design choices worth explaining:

i18n keys, not display strings. nameKey and descriptionKey are translation keys, not the text you see on screen. BrainFit supports English and Korean (roughly 2,260 translation keys total). By storing keys in the config, the registry stays language-agnostic.

isPro lives on the config. I could have maintained a separate list of Pro game IDs somewhere. Instead, the Pro flag is right on each config. One source of truth for every game's access tier.

evidenceKey is optional. Several games — particularly the Pro-exclusive ones — are backed by specific cognitive science paradigms. This field links to a localized explanation of the research basis, displayed in the game detail screen. Games without strong specific evidence simply leave it null.

initialLevel = 1, maxLevel = 20. These are defaults. The actual starting level for a session is determined by the DifficultyAdapter using the player's current Elo rating — but the config establishes the bounds. More on this below.

The Registry: One const List

The registry in lib/features/games/game_registry.dart is intentionally simple — a top-level const list:

const allGames = <GameConfig>[
  // WIS - Memory
  GameConfig(id: 'pattern_matrix', nameKey: 'game.pattern_matrix',
    descriptionKey: 'game.pattern_matrix.desc', area: BrainArea.wis,
    timeLimitSeconds: 60),
  GameConfig(id: 'face_memory', nameKey: 'game.face_memory',
    descriptionKey: 'game.face_memory.desc', area: BrainArea.wis,
    timeLimitSeconds: 60),
  GameConfig(id: 'card_flip', nameKey: 'game.card_flip',
    descriptionKey: 'game.card_flip.desc', area: BrainArea.wis,
    timeLimitSeconds: 90),
  GameConfig(id: 'sound_sequence', nameKey: 'game.sound_sequence',
    descriptionKey: 'game.sound_sequence.desc', area: BrainArea.wis,
    timeLimitSeconds: 60),
  GameConfig(id: 'n_back', nameKey: 'game.n_back',
    descriptionKey: 'game.n_back.desc', area: BrainArea.wis,
    timeLimitSeconds: 60, evidenceKey: 'game.n_back.evidence'),
  GameConfig(id: 'memory_palace', nameKey: 'game.memory_palace',
    descriptionKey: 'game.memory_palace.desc', area: BrainArea.wis,
    timeLimitSeconds: 90, isPro: true,
    evidenceKey: 'game.memory_palace.evidence'),
  // ... 26 more entries across 5 remaining areas
];

Four utility functions sit alongside the list:

List<GameConfig> getGamesByArea(BrainArea area) =>
    allGames.where((g) => g.area == area).toList();

GameConfig? getGameById(String id) =>
    allGames.where((g) => g.id == id).firstOrNull;

List<GameConfig> getProGamesByArea(BrainArea area) =>
    allGames.where((g) => g.area == area && g.isPro).toList();

List<GameConfig> getFreeGamesByArea(BrainArea area) =>
    allGames.where((g) => g.area == area && !g.isPro).toList();

These are functional-style linear scans rather than precomputed maps. With 32 items, the difference is microseconds. I decided the simplicity of no auxiliary data structure outweighed the negligible performance cost. If the game catalog ever reaches the hundreds, I would revisit.

getGameById returns a nullable GameConfig?. When loading game history records from the SQLite database, we look up games by their string ID. If a game ID in an old record no longer exists in the registry — something I try hard to prevent, but defensive code is good code — the null is handled gracefully instead of crashing.

The 32 Games: 6 Cognitive Areas, 24 Free + 8 Pro

Every game maps to one of six cognitive domains, represented by the BrainArea enum:

| Area | Code | Color | Free Games | Pro-Exclusive | |------|------|-------|------------|---------------| | Memory | WIS | Teal | PatternMatrix, CardFlip, FaceMemory, SoundSequence | NBack, MemoryPalace | | Focus | AGI | Orange | ColorStroop, SpeedTap, TrackingBall, DualTask | GoNoGo, AttentionalBlink | | Logic | INT | Purple | NumberChain, BlockPuzzle, Balance, PatternMatching | MatrixReasoning, TowerPlanning | | Language | CHA | Yellow | WordScramble, AssociationChain, FillBlank, CategorySort | VerbalFluency, VerbalAnalogy | | Processing Speed | SPD | Pink | StarlightSearch, MeteorSort | SignalSwitch, BlackholeDodge | | Spatial | SPA | Blue | PlanetRotation, SpaceMaze | NebulaAssembly, OrbitPredict |

The split is 24 free and 8 Pro-exclusive. The principle was that every area must have at least 2 free games so that free users can train all six cognitive domains. No area is locked behind a paywall entirely. The Pro games tend to be the ones grounded in specific paradigms from cognitive science literature — N-Back, Go/No-Go, Attentional Blink — that appeal to users who are more serious about tracking brain health improvements.

The WIS, AGI, INT, and CHA areas each have 4 free + 2 Pro (6 total). SPD and SPA have 2 free + 2 Pro (4 total). The asymmetry reflects that spatial and speed games are harder to vary without them feeling repetitive, so I chose depth over breadth there.

Each area's color is defined on the enum itself via an extension method. Any widget can call config.area.color and get the right tint without a lookup table scattered through the UI layer.

Time Limits: Shorter Than You Might Expect

Looking at the actual timeLimitSeconds values across the registry, the range is 30 to 90 seconds:

  • 30 seconds: SpeedTap, VerbalFluency, MeteorSort
  • 45 seconds: ColorStroop, CategorySort, StarlightSearch, BlackholeDodge
  • 60 seconds: the majority (PatternMatrix, FaceMemory, SoundSequence, NBack, TrackingBall, DualTask, GoNoGo, NumberChain, Balance, PatternMatching, MatrixReasoning, WordScramble, AssociationChain, FillBlank, VerbalAnalogy, SignalSwitch, PlanetRotation, SpaceMaze, OrbitPredict)
  • 90 seconds: CardFlip, MemoryPalace, AttentionalBlink, BlockPuzzle, TowerPlanning, NebulaAssembly

Short, intense bursts. The cognitive science reasoning is that focused attention degrades with session length. The product reasoning is that 60 seconds fits into any gap in a day, which matters for daily retention.

The GameController constructor accepts a gameSpeedMultiplier parameter. The Pro-exclusive Time Attack challenge mode passes a value that compresses the time limit to 30 seconds (with a 1.3× score multiplier). Hard Mode keeps the original time but adds a 3-life constraint, bumps the starting Elo by 200 and starting level by 3, and applies a 1.5× score multiplier.

GameController: The Shared State Machine

While GameConfig is pure data, GameController is the runtime engine that all 32 games share. It is a ChangeNotifier that owns a GameState snapshot and drives four phases:

enum GamePhase { ready, playing, paused, finished }

The lifecycle is strictly one-directional: ready → playing → paused (optional) → finished. You cannot transition backward. If a player wants to play the same game again, a new GameController instance is created.

The constructor wires GameConfig to initial state and plugs in the DifficultyAdapter:

GameController({
  required this.config,
  double? playerElo,
  int? gamesPlayed,
  bool vibrationEnabled = true,
  double gameSpeedMultiplier = 1.0,
}) : _state = GameState(
       totalTimeMs: (config.timeLimitSeconds * gameSpeedMultiplier * 1000).round(),
       remainingTimeMs: (config.timeLimitSeconds * gameSpeedMultiplier * 1000).round(),
       level: config.initialLevel,
     ) {
  _difficulty = DifficultyAdapter(
    minLevel: config.initialLevel,
    maxLevel: config.maxLevel,
    playerElo: playerElo,
    gamesPlayed: gamesPlayed,
  );
  _state = _state.copyWith(level: _difficulty.currentLevel);
}

The DifficultyAdapter maps the player's Elo rating (range 600–1800, starting at 1000) to an initial level between 1 and 20. A new player starts at level 1. A player with a WIS Elo of 1400 might start PatternMatrix at level 10. The adapter then continues adjusting in real time, targeting 85% accuracy — nudging the level up when the player is breezing through, down when they are struggling.

GameState: Immutable Snapshots

The GameState class uses an immutable design with copyWith:

class GameState {
  final GamePhase phase;
  final int score;
  final int level;
  final int combo;
  final int maxCombo;
  final int correctCount;
  final int wrongCount;
  final int remainingTimeMs;
  final int totalTimeMs;
  final int missStreak;
  final int lastPointsEarned;

  double get timeProgress => remainingTimeMs / totalTimeMs;
  int get totalAttempts => correctCount + wrongCount;
  double get accuracy => totalAttempts == 0 ? 0 : correctCount / totalAttempts;
  bool get isPlaying => phase == GamePhase.playing;
}

On every state change, the controller creates a new GameState via copyWith and calls notifyListeners(). Game widgets hold a reference to the current state without worrying about it mutating underneath them. This is idiomatic Flutter with ChangeNotifier and plays well with Riverpod's provider watchers.

The Event Stream

The controller broadcasts a stream of 14 distinct game events:

enum GameEvent {
  combo3, combo5, combo10, combo20,
  comboBreak,
  missStreak3, missStreak5,
  levelUp, levelDown,
  timeWarning30s, timeWarning10s,
  perfectRound,
  correct, wrong,
}

The stream uses a broadcast StreamController, so multiple subscribers can listen simultaneously — the combo animation widget, the timer bar, and the haptic feedback handler all receive the same events without knowing about each other. The controller has no opinion about what the UI does with these signals. When timeWarning10s fires, the controller also triggers HapticFeedback.heavyImpact() directly, since that feedback should always happen regardless of whether any widget is listening.

Recording Answers and Scoring

Individual game widgets call either recordCorrect() or recordWrong() — that is the entire API surface between a game and the shared engine. Here is the full recordCorrect implementation:

void recordCorrect() {
  if (!_state.isPlaying) return;

  final responseTime = _questionStartTime != null
      ? DateTime.now().difference(_questionStartTime!).inMilliseconds
      : 2000;

  final newCombo = _state.combo + 1;
  final points = ScoreCalculator.calculateScore(
    level: _state.level,
    responseTimeMs: responseTime,
    combo: newCombo,
  );

  _difficulty.recordAnswer(correct: true);
  final newLevel = _difficulty.currentLevel;

  _state = _state.copyWith(
    score: _state.score + points,
    combo: newCombo,
    maxCombo: max(newCombo, _state.maxCombo),
    correctCount: _state.correctCount + 1,
    level: newLevel,
    missStreak: 0,
    lastPointsEarned: points,
  );

  if (_vibrationEnabled) HapticFeedback.lightImpact();
  _eventController.add(GameEvent.correct);
  if (newLevel > prevLevel) _eventController.add(GameEvent.levelUp);
  if (newCombo == 3) _eventController.add(GameEvent.combo3);
  // ... combo milestones up to 20
  _questionStartTime = DateTime.now();
  notifyListeners();
}

The score formula is: base = level × responseTimeBonus × comboMultiplier. The same tap scores dramatically more at level 15 with a 20-combo streak and a 400ms response time versus level 1 with no combo and a 2-second response time. This creates a real incentive to stay focused throughout the session rather than just grinding through answers.

Pause Correction

The pause/resume handling has a subtle detail that matters for scoring fairness. When a player pauses, the controller records the timestamp. On resume, it adjusts _questionStartTime by the pause duration:

void resume() {
  if (_questionStartTime != null && _pausedAt != null) {
    _questionStartTime = _questionStartTime!.add(
      DateTime.now().difference(_pausedAt!),
    );
  }
  _pausedAt = null;
  _state = _state.copyWith(phase: GamePhase.playing);
  _startTimer();
  notifyListeners();
}

Without this correction, a player who pauses to answer a phone call would return to a 60-second "response time" on the pending question — and the scoring formula would treat that as an extremely slow answer. The adjustment ensures the reaction time clock only measures the time the player was actually looking at the question.

Lazy Initialization: What Happens at Each Stage

To be concrete about what "lazy" means in practice:

At startup (main()):

  • WidgetsFlutterBinding.ensureInitialized() runs
  • Sentry is configured if a DSN environment variable exists
  • Riverpod ProviderScope is created with a small set of overrides (subscription tier, screenshot mode providers)
  • The app widget tree starts building
  • Zero GameController instances are created
  • Zero game widgets are constructed
  • allGames is available immediately — it is part of the binary's data segment

When the Training tab renders:

  • The screen calls getGamesByArea(selectedArea) to build the list UI
  • Each game card displays config.nameKey (resolved through i18n) and config.area.color
  • Still zero GameController instances

When the player taps a game:

  • GoRouter navigates to the game route
  • The game screen widget builds its GameController with the config and the player's current Elo for that area
  • The game-specific widget tree is constructed
  • Assets load

This means startup time does not scale with the number of registered games. I can add a 33rd game to the const list and the app's boot time changes by zero milliseconds.

How the Registry Connects to the Rest of the App

The registry is the spine of several features beyond the Training screen:

Daily mission generator queries allGames to randomly select games for daily objectives. The isPro flag lets the generator respect the user's subscription tier — free users only get missions for free games.

Statistics screens use getGameById(record.gameId) to look up metadata when rendering historical session records from SQLite's GameRecords table.

Screenshot data seeder (the debug mode that populates mock data for App Store screenshots) uses 16 game IDs drawn from allGames to create realistic-looking game history.

Milestone system tracks achievements like "play every game in the WIS area" by iterating over getGamesByArea(BrainArea.wis).

Difficulty Adaptation Per Game

Every GameController gets its own DifficultyAdapter, initialized with the player's Elo for that specific BrainArea. BrainFit tracks six separate Elo ratings (one per area, range 600–1800, starting at 1000). A player can have a high WIS Elo from memory training while still being a beginner in SPD games.

The adapter uses a K-factor of 32 for the first 20 games played, dropping to 16 after that. Wrong answers use an asymmetric K of ×0.5 — losing is less punishing than winning is rewarding, which keeps the system from deflating new players too fast. The session target is 85% accuracy; the adapter nudges the level up or down after each answer to stay near that target.

The Elo-to-level mapping (1000 Elo → level 1, 1800 Elo → level 20 roughly) and the full Elo update math are covered in depth in the Elo and Adaptive Difficulty post.

Adding a New Game: What It Takes

Given this architecture, adding a 33rd game requires exactly three steps:

  1. Add a GameConfig entry to allGames in game_registry.dart
  2. Create the game widget class (which calls controller.recordCorrect() and controller.recordWrong())
  3. Add a route to the GoRouter configuration

The game widget does not implement a timer, a score formula, combo tracking, or difficulty adaptation. It presents questions and reports answers. Everything else is handled by the shared infrastructure.

This was important as BrainFit scaled from the original 16 games (Wave 1) to the current 32 (Wave 3). Adding 16 games in a single wave was manageable precisely because each new game was just a GameConfig entry plus a widget — not a full reimplementation of the game engine.

Why Not a Map, Code Generation, or a Service Locator?

Why a list and not Map<String, GameConfig>? The most frequent access patterns are "all games for area X" and "iterate all games." Both are natural with a list. The getGameById scan is O(32), which is O(1) in any practical sense. A map would give O(1) ID lookup at the cost of maintaining a second data structure that has to stay in sync with the list. Not worth it at this scale.

Why not code generation? BrainFit already uses build_runner for Drift (the SQLite ORM that manages 26 tables across 14 DAOs). Adding it for a 32-entry config file would add a build step for no real gain. If the catalog grew to 200+ entries, annotations and generation would start to pay off.

Why not a service locator like GetIt? Riverpod handles all dependency injection across the 332-file codebase. Introducing a second DI mechanism only for game registration would be inconsistent. The const list approach requires no framework at all — it is just a Dart file.

Wrapping Up

The game registry pattern in BrainFit comes down to one principle applied consistently: pay costs at the right time. Metadata costs are paid at compile time (zero runtime allocation). Widget and controller costs are paid at play time (only for the one game being played). Startup time stays flat regardless of how many games are in the catalog.

The codebase that manages these 32 games sits at 332 files and roughly 115,900 lines across lib/, with 234 test files covering 2,273 test cases. The registry itself — 61 lines of Dart — is one of the more load-bearing files in the project. Sometimes the simplest design is the right one.

Managing 32 Mini-Games with a Lazy Registry Pattern