When I built KnotGlide, a calm arrow puzzle game, the whole game rested on one simple move: tap an arrow whose path is clear and it glides out of the maze along its own route. A blocked arrow nudges forward and shows what is in its way, so every tap teaches the next move. The game ships with 100 curated levels in 8 chapters, a daily puzzle, time challenges and an endless mode — all fully offline, with no account and no purchases. You can see the finished game in the KnotGlide case study.
This guide shows how to build that kind of game in Flutter: model the board as plain Dart data, write the clear-path check, animate the glide, handle ambiguous taps fairly, design levels that are always solvable, and add a daily puzzle with streaks — all on-device, with no network at all.
What is an arrow-escape puzzle game?
An arrow-escape puzzle game is a grid puzzle where every piece is an arrow pointing in one of four directions, and the only move is tapping an arrow to slide it out of the board along the direction it points. An arrow escapes only when every cell between it and the edge of the board is empty; if another arrow blocks the way, the tapped arrow nudges forward and shows what is in its way instead of moving. The level is complete when every arrow has escaped, and the puzzle is in the order: freeing one arrow can open the path for another, so the player has to find the right sequence. That is the whole ruleset — no dragging, no rotating, no matching — which is why the game stays calm and readable even on a crowded board. Boards in KnotGlide come in many shapes, including circles, hexagons, rings, diamonds, leaves and hourglasses, but under the hood each one is still a grid of cells, so the same code drives every shape.
Model the board as plain Dart data
Model the board as plain Dart data before any widget touches it, because every other part of the game — the escape check, the animation, the save system — reads from the same model. A board is a grid of cells, and each cell either holds an arrow or is empty. An arrow knows its row, its column and the direction it points, and a direction knows how to step one cell: up subtracts a row, down adds a row, left subtracts a column and right adds a column. Keep the model free of widgets, colours and animation state so you can test it in plain Dart unit tests, generate levels from it, and serialize it for saving mid-level progress.
\`dart
enum Direction { up, down, left, right }
extension DirectionStep on Direction {
(int, int) get step => switch (this) {
Direction.up => (-1, 0),
Direction.down => (1, 0),
Direction.left => (0, -1),
Direction.right => (0, 1),
};
}
class Arrow {
Arrow({required this.row, required this.col, required this.direction});
int row;
int col;
final Direction direction;
}
class Board {
Board({required this.rows, required this.cols, required this.arrows});
final int rows;
final int cols;
final List arrows;
bool isInside(int row, int col) =>
row >= 0 && row < rows && col >= 0 && col < cols;
Arrow? arrowAt(int row, int col) {
for (final arrow in arrows) {
if (arrow.row == row && arrow.col == col) return arrow;
}
return null;
}
}
`\
Non-rectangular boards need no special case: mark some cells as walls and isInside returns false for them, keeping one code path for every shape in the game.
How do you check whether an arrow can escape?
An arrow can escape when every cell between it and the edge of the board, in the direction it points, is empty. Walk from the arrow one cell at a time in its direction: if you step outside the board, the path is clear; if you meet another arrow first, the path is blocked. The check is a short loop and the single source of truth for the whole game — the tap handler, the hint system and the level solver all call the same function. When the path is blocked, do not just ignore the tap: nudge the arrow a few pixels toward the blocker and back, so the player sees exactly what is in the way.
\`dart
bool canEscape(Board board, Arrow arrow) {
final (dRow, dCol) = arrow.direction.step;
var row = arrow.row + dRow;
var col = arrow.col + dCol;
while (board.isInside(row, col)) {
if (board.arrowAt(row, col) != null) return false;
row += dRow;
col += dCol;
}
return true;
}
void tryEscape(Board board, Arrow arrow) {
if (canEscape(board, arrow)) {
board.arrows.remove(arrow);
} else {
// Play the nudge animation: the arrow shows its blocker.
}
}
`\
The hint system is the same function run over every arrow: scan the board, collect the arrows where canEscape returns true, and light one of them up. In KnotGlide the hint lights up an arrow that can escape right now, so it never suggests a move the game itself would refuse.
How do you animate the glide?
Animate the glide with an AnimationController and a Tween between the arrow's current cell and the board edge, so the arrow travels along its own route instead of teleporting out. Scale the duration to the distance — about 90 milliseconds per cell, capped at roughly half a second — so a short hop feels snappy and a long glide across the board still feels fast. Draw the board with a CustomPainter and render each arrow as a positioned widget on top of it, so the animation moves the widget while the painter stays still; the Flutter documentation for CustomPainter shows the pattern. When the animation completes, remove the arrow from the board model and rebuild. Respect the player's reduce-motion setting: when MediaQuery disables animations, skip the tween and remove the arrow immediately, because a calm game should never force motion on someone who asked for less of it. In KnotGlide the reduce-motion, haptics, sound and music switches are separate, so a player can silence the game without losing the feedback they do want. If you are new to Flutter animation, the breathing pacer guide walks through AnimationController and curved tweens in a smaller example.
\dart
Future<void> animateEscape({
required AnimationController controller,
required int distance,
required VoidCallback onDone,
}) async {
final duration = Duration(milliseconds: (distance * 90).clamp(120, 500));
controller.duration = duration;
await controller.forward(from: 0);
onDone();
}
\\
How do you make taps feel fair?
Make taps feel fair by never guessing when a tap is ambiguous. On a crowded board a fingertip can sit between two arrows, and picking the wrong one punishes the player for the game's imprecision. When a tap lands near more than one arrow, light both of them up and let the player choose — the tap becomes a selection, not a mistake. KnotGlide uses exactly this rule: a tap between two arrows never guesses, both glow and the player chooses. Add an optional Tap Twice mode on top of it: the first tap previews the escape path, highlighting every cell the arrow would travel through, and the second tap sends it out. The preview costs nothing to implement — it reuses canEscape and draws the path — and it turns the most frustrating moment in the game, tapping an arrow that cannot move, into a deliberate choice. Three hearts per level give the player room to learn: a blocked tap costs a heart, and the level ends when the hearts run out.
How do you design levels that are always solvable?
Design levels that are always solvable by building them backwards from the solved state. Start with an empty board, place arrows in reverse escape order — the last arrow to escape goes in first, then the arrow that blocks it, and so on — and every level you build this way has at least one solution by construction. Author each level as a small grid of characters, one character per cell, where a letter marks an arrow and its direction and a dot marks an empty cell; the level file stays readable and editable by hand. Then verify every level with a solver in your tests: the solver tries each escapable arrow in turn, recurses, and reports any level with no solution, so a broken level never reaches the player. Difficulty comes from the number of arrows and how many of them block each other, not from new rules: early chapters use three or four arrows with obvious first moves, later chapters pack the board so the player has to plan several moves ahead. KnotGlide ships 100 curated levels across 8 chapters — Loose Threads, First Knots, Twists, Tangles, Grand Knots, Snarls, Labyrinths and Gordian Knots — with an endless mode after level 100. A Perfect Glide star for clearing a level with no blocked taps gives skilled players a reason to replay.
How do you add a daily puzzle and streaks?
Add a daily puzzle by deriving the puzzle from the date instead of storing a list of puzzles. Use the current date as a seed — for example, the number of days since a fixed start date — and generate or pick the board from that seed, so every player in the world gets the same puzzle on the same day and the puzzle changes at midnight without any server. Store the streak on-device: the last date the player completed a daily puzzle and the current streak count. When the player finishes today's puzzle, compare the stored date with yesterday: if it matches, add one to the streak; if it is older, reset the streak to one; if it is today already, do nothing, because replaying a finished puzzle should never inflate the streak. Monthly trophies are the same idea at a larger scale — count completed days in the current month and award a trophy at fixed thresholds. Keep past days playable, as KnotGlide does, so a missed day never locks a player out.
\`dart
Future recordDailyPuzzle({
required DateTime today,
required DateTime? lastPlayed,
required int currentStreak,
}) async {
final yesterday = today.subtract(const Duration(days: 1));
final isSameDay = lastPlayed != null &&
lastPlayed.year == today.year &&
lastPlayed.month == today.month &&
lastPlayed.day == today.day;
if (isSameDay) return;
final isYesterday = lastPlayed != null &&
lastPlayed.year == yesterday.year &&
lastPlayed.month == yesterday.month &&
lastPlayed.day == yesterday.day;
final newStreak = isYesterday ? currentStreak + 1 : 1;
// Save newStreak and today with shared_preferences.
}
`\
How do you keep progress safe and the game calm?
Keep progress safe by saving the board state after every move, so a player who closes the app mid-level resumes exactly where they left off. Serialize the model you already built — the list of arrows with their positions and directions, the hearts left, and the level id — into a small JSON map and write it to on-device storage. Loading is the reverse: read the map, rebuild the board, and the player never notices the app restarted. Keep the game calm by removing pressure from the main levels: no timer, no ads between levels and no banners, with rewarded ads only when the player chooses — for a hint, an extra heart or more seconds in a time challenge. Time challenges are a separate mode with their own rules, so the 12 challenges with up to 36 stars in KnotGlide never leak a countdown into the relaxed levels, and a blocked tap there costs 5 seconds instead of a heart. Light and dark themes, separate sound, music and haptics switches, and pinch-to-zoom on big boards round out the experience. The same on-device, offline-first thinking works for any calm app — the color-blind friendly game guide shows how accessibility choices like symbols alongside colours shape a game from the start.
Get in touch and let's talk about your project.
Originally published at uzairxdev.online
Top comments (0)