When to use this project
A merge idle game is a useful next step after a Flutter team has already built ordinary screens, routing, environment configuration, and basic state management. It is small enough to finish without a backend, but it is complex enough to expose real mobile engineering problems: grid state, drag gestures, deterministic merge rules, local persistence, offline rewards, Android build commands, and useful logs. A simple list page rarely forces those boundaries. A game does, because every drag, purchase, app restart, and resume path can reveal a mismatch between what the UI shows and what the model actually stores.
The practice project in this article is called merge_garden. The player owns a 4 by 4 board. Each cell can contain a seed item with a level. Dragging one item onto an empty cell moves it. Dragging two items of the same level together creates a higher-level item and grants a small coin reward. Coins buy new level-one seeds, and the board is saved locally so the player can leave and return. The project deliberately keeps art simple. The goal is not to build a polished store-ready game in one pass; the goal is to build a clean Flutter Android skeleton that can be extended later with tasks, collections, animation, sound, ads, or cloud saves.
This topic naturally follows the site article about Flutter Android flavor and dart-define configuration. That earlier work makes the build environment predictable. This project uses that foundation to build a real feature loop: buy, drag, merge, produce coins, save, restore, and review logs when something goes wrong.
Project structure and configuration
Start by creating a normal Flutter app, then split the game into small files before the page becomes hard to reason about. A common pitfall in small game experiments is putting the grid, item model, timer, persistence, and drag logic into a single StatefulWidget. That works for the first hour and becomes painful as soon as you add saving or tests. Use a simple structure like this:
merge_garden/
lib/
main.dart
app.dart
game/
merge_game_page.dart
game_controller.dart
game_models.dart
merge_rules.dart
save_store.dart
game_logger.dart
test/
merge_rules_test.dart
The setup commands are intentionally short:
flutter create merge_garden
cd merge_garden
flutter pub add shared_preferences
flutter pub add dev:very_good_analysis
flutter analyze
flutter test
flutter run -d emulator-5554
shared_preferences is enough for the first version because the save payload is a small JSON snapshot. A heavier database would be premature unless the game has inventories, multiple gardens, event history, or a large collection catalog. For state management, ChangeNotifier is also acceptable at this stage. If the game grows into multiple features, you can move to Riverpod, Bloc, or another state layer later. The important boundary is not the library choice. The important boundary is that merge rules stay testable without a widget tree, persistence stays isolated, and UI code does not mutate the board directly.
Data model: store state, not widgets
The board should store domain objects. It should never store widgets, colors, or rendered cells. A cell either contains a MergeItem or null. The item stores a stable id, a level, and its creation time. A game snapshot stores coins, all slots, and the save time.
class MergeItem {
const MergeItem({required this.id, required this.level, required this.createdAt});
final String id;
final int level;
final DateTime createdAt;
int get coinPerMinute => level * 2;
Map<String, Object?> toJson() => {
'id': id,
'level': level,
'createdAt': createdAt.toIso8601String(),
};
factory MergeItem.fromJson(Map<String, Object?> json) => MergeItem(
id: json['id']! as String,
level: json['level']! as int,
createdAt: DateTime.parse(json['createdAt']! as String),
);
}
class GameSnapshot {
const GameSnapshot({required this.coins, required this.slots, required this.savedAt});
final int coins;
final List<MergeItem?> slots;
final DateTime savedAt;
}
This model has three useful properties. First, the board is a fixed-length list, so drag-and-drop only has to move indexes. Second, time is stored as an ISO string, which makes local save files easy to inspect during debugging. Third, coin production is derived from the level instead of being duplicated in the save file. That makes balancing safer. If you decide later that level three should produce a different amount, you can change one rule instead of migrating many saved numeric fields.
Merge rules: keep the core logic pure
The merge algorithm should be a pure Dart function. It should not know about BuildContext, GridView, animation, or Android lifecycle events. The minimum rule set is straightforward: a drag from a cell to itself is blocked; a drag from an empty source is blocked; a target that is empty receives the source item; two items with the same level merge if they are below the max level; otherwise the two items swap positions.
enum MergeResultType { moved, merged, swapped, blocked }
class MergeResult {
const MergeResult(this.type, this.slots, {this.rewardCoins = 0});
final MergeResultType type;
final List<MergeItem?> slots;
final int rewardCoins;
}
MergeResult applyMove({
required List<MergeItem?> slots,
required int from,
required int to,
required String Function() nextId,
int maxLevel = 6,
}) {
if (from == to || from < 0 || to < 0 || from >= slots.length || to >= slots.length) {
return MergeResult(MergeResultType.blocked, List.of(slots));
}
final next = List<MergeItem?>.of(slots);
final source = next[from];
final target = next[to];
if (source == null) return MergeResult(MergeResultType.blocked, next);
if (target == null) {
next[to] = source;
next[from] = null;
return MergeResult(MergeResultType.moved, next);
}
if (source.level == target.level && source.level < maxLevel) {
next[to] = MergeItem(id: nextId(), level: source.level + 1, createdAt: DateTime.now());
next[from] = null;
return MergeResult(MergeResultType.merged, next, rewardCoins: source.level * 5);
}
next[to] = source;
next[from] = target;
return MergeResult(MergeResultType.swapped, next);
}
Write tests before wiring this into the page. The command is flutter test test/merge_rules_test.dart. Tests should validate movement, same-level merging, different-level swapping, max-level blocking, invalid indexes, and empty source cells. This step is not ceremony. It prevents a very common pitfall: debugging a drag gesture for half an hour when the real problem is a broken rule branch.
Controller steps: coins, purchases, saves, and logs
GameController coordinates rules and side effects. It owns the current coins and slots, calls the pure merge function, asks the save store to persist snapshots, and writes structured log events. Do not hide logs behind vague strings such as move success. Use event names and fields that explain what changed.
class GameController extends ChangeNotifier {
GameController({required SaveStore store, required GameLogger logger})
: _store = store,
_logger = logger;
final SaveStore _store;
final GameLogger _logger;
int coins = 50;
List<MergeItem?> slots = List<MergeItem?>.filled(16, null);
Future<void> load() async {
final snapshot = await _store.load();
if (snapshot == null) return;
slots = snapshot.slots;
coins = snapshot.coins + _offlineCoins(snapshot);
_logger.info('game_loaded', {'coins': coins});
notifyListeners();
}
Future<void> buySeed() async {
const price = 10;
final emptyIndex = slots.indexWhere((item) => item == null);
if (coins < price || emptyIndex == -1) {
_logger.info('buy_blocked', {'coins': coins, 'emptyIndex': emptyIndex});
return;
}
coins -= price;
slots[emptyIndex] = MergeItem(id: DateTime.now().microsecondsSinceEpoch.toString(), level: 1, createdAt: DateTime.now());
await save();
notifyListeners();
}
Future<void> moveItem(int from, int to) async {
final before = coins;
final result = applyMove(slots: slots, from: from, to: to, nextId: () => DateTime.now().microsecondsSinceEpoch.toString());
slots = result.slots;
coins += result.rewardCoins;
_logger.info('item_move', {'from': from, 'to': to, 'type': result.type.name, 'coinDelta': coins - before});
await save();
notifyListeners();
}
}
Offline rewards should be conservative in the first version. Calculate the minute difference between savedAt and now, multiply it by the production rate, and cap the duration at a fixed limit such as eight hours. Without a cap, a player who changes device time or returns after a long break can distort the entire economy. If the project later adds a server, local calculation can become an estimate while the backend confirms the final reward.
UI implementation with GridView and drag targets
The UI should render the board and forward user intent to the controller. Each cell is a DragTarget<int>. If the cell contains an item, the item is wrapped in Draggable<int>. The drag payload is the source index, not the item object. That makes the controller the only place where board mutations happen.
class MergeGrid extends StatelessWidget {
const MergeGrid({super.key, required this.controller});
final GameController controller;
@override
Widget build(BuildContext context) {
return GridView.builder(
padding: const EdgeInsets.all(16),
itemCount: controller.slots.length,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
crossAxisSpacing: 10,
mainAxisSpacing: 10,
),
itemBuilder: (context, index) {
final item = controller.slots[index];
return DragTarget<int>(
onAcceptWithDetails: (details) => controller.moveItem(details.data, index),
builder: (context, candidate, rejected) {
final cell = DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: candidate.isEmpty ? const Color(0xffeef4f0) : const Color(0xffd8f3dc),
),
child: Center(child: item == null ? const SizedBox.shrink() : Text('Lv.${item.level}')),
);
if (item == null) return cell;
return Draggable<int>(
data: index,
feedback: Material(child: SizedBox(width: 72, height: 72, child: Center(child: Text('Lv.${item.level}')))),
childWhenDragging: Opacity(opacity: .35, child: cell),
child: cell,
);
},
);
},
);
}
}
On Android, touch target size matters. Avoid placing the board inside another unconstrained scroll view. Keep the board stable and put controls below it: coin count, buy button, save button, and reset button. The first screen should be the playable game, not an explanation page. For a small tool or game, users need the core experience immediately.
Local save store and versioning
The save store is a small adapter around shared_preferences. It should only serialize and deserialize snapshots. It should not contain merge rules or UI assumptions.
class SaveStore {
static const _key = 'merge_garden_snapshot_v1';
Future<void> save(GameSnapshot snapshot) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_key, jsonEncode({
'coins': snapshot.coins,
'savedAt': snapshot.savedAt.toIso8601String(),
'slots': snapshot.slots.map((item) => item?.toJson()).toList(),
}));
}
Future<GameSnapshot?> load() async {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(_key);
if (raw == null) return null;
final json = jsonDecode(raw) as Map<String, Object?>;
final slots = (json['slots']! as List).map((item) => item == null ? null : MergeItem.fromJson(item as Map<String, Object?>)).toList();
return GameSnapshot(coins: json['coins']! as int, slots: slots, savedAt: DateTime.parse(json['savedAt']! as String));
}
}
The _v1 suffix is part of the configuration strategy. When you add a collection book, task progress, rare items, or multiple boards, do not silently change the old shape. Add a new key or write a migration path. Also log decode failures with an event such as save_decode_failed, but do not upload the full save payload to a remote logging service. A save file may contain player progress or test account state, and logs should stay useful without becoming a privacy problem.
Validate the game before adding features
Validation should happen in layers. Do not rely only on playing the game once on an emulator. A practical checklist is:
- Run
flutter analyzeand fix static warnings. - Run
flutter testand keep merge rules covered by unit tests. - Run
flutter run -d <device>and perform at least twenty drag operations on an emulator and a physical Android device. - Buy items until the board is full and validate that
buy_blockedappears in the log. - Close the app, reopen it, and confirm that coins, levels, slots, and offline rewards are restored.
- Run
flutter build apk --debugto validate the Android build chain. - Review logs for
game_loaded,item_move,buy_blocked, and save failures.
Only after this loop is stable should you add animation, sound, ads, or in-app purchases. Those features amplify state bugs. They do not fix them.
Pitfalls to avoid
The first pitfall is mutating the board directly inside widgets. All state changes should go through controller methods. The second pitfall is passing the whole item as the drag payload. Passing the source index keeps the drag simple, but for a production game you should also check the source item id before applying the move, because purchases or resets during a drag can make an index point to a different item. The third pitfall is unlimited offline rewards. Always cap the reward duration and log the final calculation. The fourth pitfall is validating only on an emulator. Long press behavior, app backgrounding, Android back navigation, and low-end device frame timing can reveal different issues on real hardware. The fifth pitfall is adding art complexity before logic is stable. Text labels and simple colors are enough for the first technical milestone.
Review and next steps
A useful review asks more than whether the game feels playable. Check whether the merge rules are testable without a UI, whether the save format can be migrated, whether logs explain an unexpected merge, whether Android build commands are repeatable, and whether the board is usable on smaller screens. From there, the next steps are clear: add a task system, add a collection book, introduce simple animations, and write widget tests for the buy button and drag targets. At that point, merge_garden is no longer a demo screen. It is a maintainable Flutter Android mini-game foundation that can support real iteration.