DEV Community

Eymen Can Orman
Eymen Can Orman

Posted on Edited on

Building MoveLab: 7 Real-World Engineering Challenges I Solved While Creating a Mobile Chess Analysis Engine in Flutter

Building MoveLab: 7 Real-World Engineering Challenges I Solved While Creating a Mobile Chess Analysis Engine in Flutter

Building a chess app for create a player style card with their games is a summer project. Building a production-grade mobile chess analysis laboratory—one that runs Stockfish locally with Multi-PV support, integrates Lichess and Chess.com APIs, profiles player styles, queries 7-piece Syzygy endgame tablebases, and renders 5,000+ Grandmaster games offline—is a completely different story.

Over the past several months, I built and shipped MoveLab (open-sourced on GitHub), a cross-platform chess analysis app written in Flutter and Dart.

Along the way, almost every major subsystem broke at least once in ways I didn't anticipate. Here is a transparent technical deep-dive into the hardest problems I faced while building MoveLab, why my initial approaches failed, and how I ultimately solved them.


🏗️ High-Level Architecture

Before diving into the bugs, here is how MoveLab is structured under the hood:

flowchart TD
    UI["Flutter UI Layer (Custom Board, Multi-PV Panel, Style Cards)"]
    Tree["Game Tree Engine (MoveNode DAG, SAN/UCI Translator, NAGs)"]
    SF["Local Stockfish Isolate (UCI Protocol, MultiPV 3, Configurable Threads/Depth)"]
    Ext["External APIs (Lichess OAuth2/Masters/Syzygy, Chess.com Public API, Vercel Puzzle API)"]
    Sync["Persistence & Cloud (SharedPreferences + Firebase Auth & Firestore Sync)"]

    UI <--> Tree
    Tree <--> SF
    Tree <--> Ext
    UI <--> Sync
    Ext <--> Sync

Challenge 1: Taming Local Stockfish Output, Async Race Conditions, and UI Layout Shifts

The Problem

MoveLab runs Stockfish locally on the user's device via the Universal Chess Interface (UCI) protocol (setoption name MultiPV value 3, configurable depth 1–30, and CPU threads 1–8). Three painful issues emerged as soon as I connected the engine stream to the Flutter UI:

  1. Machine Coordinates vs. Human Notation: Stockfish emits principal variations (PV) in raw UCI format (e2e4 e7e5 g1f3 b8c6 or e1g1 for castling). Displaying raw coordinates in the analysis panel looked amateurish, yet I still needed the raw UCI strings to draw engine suggestion arrows (MultiArrowPainter) and allow single-tap line playback on the board.
  2. Stale Async Stream Race Conditions: When a user rapidly tapped through a game (> / <), Stockfish stdout events from position $N$ would arrive milliseconds after the board had already transitioned to position $N+1$, briefly painting the wrong evaluation bar and arrow on the new board.
  3. The "Jumping Chessboard" Layout Shift: Every time a move was made, the 3 Multi-PV lines were cleared (_multiPvLines = ['', '', '']) while awaiting the new evaluation. Because the text rows collapsed in height when empty, the entire chessboard underneath jumped upward by ~60 pixels and snapped back down a split second later.

The Solution

  • Dual-Representation Pipeline (NotationHelper): I kept all internal engine state in UCI format so arrow rendering and tap-to-play logic remained fast and unambiguous, and built a just-in-time translator (formatUciLineToSan) that clones a lightweight board instance at the current FEN, simulates the UCI continuation sequence, and emits clean Standard Algebraic Notation (1. e4 e5 2. Nf3 Nc6, O-O) for the UI.
  • FEN Guarding & Fixed-Height Containers: I added strict state verification (_fenList[_currentMoveIndex] == currentFen) before committing any asynchronous evaluation callback to state. Finally, to eliminate layout shift, I locked each Multi-PV row container to a strict height: 38 pixels regardless of whether the engine was initializing or streaming depth 22 results—keeping the chessboard millimeter-stable.

Challenge 2: Moving from Destructive Linear Arrays to a Lichess-Style Game Tree (MoveNode)

The Problem

In the earliest versions of MoveLab, a game was represented by two flat lists:

List<String> _fenList = [];
List<String> _allMoves = [];
int _currentMoveIndex = 0;
Enter fullscreen mode Exit fullscreen mode

If a user was reviewing move 15 of a 40-move Magnus Carlsen game (or their own Lichess match) and tapped a Stockfish suggestion, an Opening Explorer move, or dragged a piece to test a "what-if" scenario, the code executed removeRange(_currentMoveIndex + 1, _fenList.length) to append the new move. This permanently deleted the rest of the original match.

The Solution

I refactored the analysis state from flat arrays into a node-based Game Tree (MoveNode) coupled with non-destructive variation branching:

class MoveNode {
  final String fen;
  final String san;
  final String uci;
  String? nag; // !!, !, ?!, ?, ??
  final MoveNode? parent;
  final List<MoveNode> variations;

  MoveNode({
    required this.fen,
    required this.san,
    required this.uci,
    this.nag,
    this.parent,
    List<MoveNode>? variations,
  }) : variations = variations ?? [];
}
Enter fullscreen mode Exit fullscreen mode

Now, when a user clicks an engine suggestion or plays an alternative move mid-game:

  1. MoveLab first checks if the resulting FEN already matches the next mainline move (chess.fen == _fenList[_currentMoveIndex + 1]). If so, it simply advances the cursor (_goToMove).
  2. If the move diverges in Free Analysis, it creates a child MoveNode inside currentNode.variations and renders interactive variation chips below the move list so the user can switch branches or return to the main line with a single tap.
  3. In Match & Grandmaster Archive Analysis, exploring an engine line enters an ephemeral variation state and triggers startContinuousAnalysis() without mutating the underlying match history—allowing the user to step back into the real game at any moment.

Challenge 3: Re-Engineering the Chessboard Widget (Pawn Promotions, Custom Setup Mode & Move Interceptors)

The Problem

Relying on an off-the-shelf flutter_chess_board package worked for a prototype, but broke down under real user testing:

  • The Tap-to-Move Pawn Promotion Bug: Dragging a pawn to the 8th rank opened the promotion dialog as expected. However, tapping a pawn on the 7th rank and tapping a target square on the 8th rank either failed to promote entirely or triggered a completely different, unstyled fallback dialog.
  • Corrupted Board State in Opening Trainer: When practicing openings, if a user played an incorrect move, the board widget applied the move to its internal controller before notifying our parent widget, desynchronizing the visual board from the training tree.
  • Illegal FENs in the Board Editor (Setup Mode): When building the Lichess-style Board Editor (where users place/remove pieces freely on the board), clicking "Clear Board" or manually erasing a King produced a kingless FEN string, which immediately crashed the chess validator and froze Stockfish. Similarly, placing a second White King created an invalid two-King board.

The Solution

I brought the board implementation in-house (lib/custom_chess_board/) and overhauled its internals:

  1. Vector Rendering & On-Board NAG Badges: Replaced low-res bitmap board textures with crisp Flutter Container grids (0xFF769656 & 0xFFEEEED2) and overlaid Lichess-style move classification badges (!!, !, ?, ??) directly onto the top-right corner of the destination square.
  2. Pre-Commit Move Interception (onMoveAttempt): Added a synchronous gatekeeper callback that fires before a move is committed to the board state. In the Opening Trainer and Puzzle history viewer, off-book or locked-state moves are rejected before the UI mutates.
  3. Unified Promotion & Board Editor Invariants (_modifyFenSquare): Unified the tap-to-move and drag-and-drop promotion handlers into a single visual piece selector. In the Board Editor, I enforced strict FEN invariants: the "Clear" tool strips all pieces except the two Kings (K and k), and placing a King on a new square automatically relocates the existing King of that color while recalculating castling availability (KQkq).

Challenge 4: Why the Same Player Got Opposite "Playstyle Cards" on Lichess vs. Chess.com

The Problem

One of MoveLab's most popular features is the AI Playstyle Profile Card, which analyzes a player's recent games to classify them into archetypes (Aggressive, Tactical Master, Positional, Defensive, Endgame Specialist, Risk Taker, Universal) with percentage breakdowns.

During beta testing, users reported a bizarre bug: entering the exact same player's Lichess username and Chess.com username produced almost completely opposite style profiles. Furthermore, some games showed double the actual number of moves!

The Solution

Digging into the raw PGN payloads from both APIs revealed two root causes:

  1. PGN Metadata & Clock Tag Pollution: Chess.com and Lichess embed different inline comments (such as {[%clk 0:02:59.9]}) and move-number formatting (1. e4 vs 1... e5). A double-escaped regular expression (RegExp(r'^\\d+\\.+\\s*') instead of RegExp(r'^\d+\.+\s*')) combined with unstripped clock tokens caused the parser to miscount plies as full moves.
  2. Platform-Biased Heuristics: Time control distributions and average game lengths differ systematically between Lichess and Chess.com API responses. I rewrote the PGN sanitizer to strip all {...} annotations and headers cleanly, fixed the regex escaping, and recalibrated the playstyle scoring weights around platform-agnostic board features (capture density, early queen/piece activity, king safety / castling timing, and endgame transition rate).

Challenge 5: Puzzles That Rejected Valid Checkmates & Serverless Cold Starts

The Problem

Our tactical puzzle module suffered from two frustrating UX issues:

  1. Multi-Mate False Negatives: Puzzles are defined by a sequence of UCI moves from a database. In one position, the user's Queen could deliver immediate checkmate on both b6 and a5. Because the puzzle database only listed a5 as the solution, playing Qb6# was marked as "Wrong Move (-20 pts)"!
  2. Serverless Cold-Start Lag: Fetching puzzles on-demand from our Vercel API endpoint caused a 2–4 second loading spinner whenever the serverless function experienced a cold start—and worse, the app fetched a batch of 50 puzzles, used 1, and discarded the other 49!

The Solution

  • Checkmate-Aware Validation: When a user's move doesn't match the hardcoded puzzle string, MoveLab now simulates the move on an internal chess.Chess instance and checks chess.in_checkmate. If the move delivers legal checkmate, it is immediately rewarded as correct regardless of the database's preferred square!
  • 0ms Pre-Caching & 1.8s Fallback Timeout: Instead of discarding 49 puzzles, I stored the entire batch in a difficulty-indexed static memory cache (_cachedApiPuzzles) preloaded silently during HomePage.initState, replenishing in the background whenever the pool drops below 5 puzzles. If the cache is empty and the Vercel API takes longer than 1.8 seconds, the request times out gracefully and serves an instant puzzle from the embedded localPuzzlePool.
  • Opponent Lead-In Animation: Instead of dropping the user into a static position, puzzles now load one ply earlier, pause for 600ms, and animate the opponent's blunder with sound effects—while locking board input during the animation and allowing < / > history review up to the current solved ply.

Challenge 6: Scaling to 5,000+ Grandmaster Games Without Bloating the APK

The Problem

I wanted to include a Grandmaster Archive featuring over 5,000 classic games from 20 legends (Carlsen, Kasparov, Fischer, Tal, Capablanca, Nakamura, etc.) searchable by player, year, and ECO opening code. Bundling every single PGN file into the app binary forever would continuously inflate the APK/AAB download size as more masters were added, and parsing multi-thousand-game .pgn files using heavy third-party parsers froze the UI thread.

The Solution

  1. Custom Zero-Dependency Streaming PGN Parser (splitPgnGames & parseOneGame): I wrote a lightweight Dart parser in lib/master_archive_pages.dart that splits multi-game PGN files on the fly, extracts player titles (GM, IM), ELO ratings, and ECO codes, and maps ECO codes to human-readable opening names (e.g., C50 — Italian Game).
  2. Hybrid Cloud + Local Caching Architecture: The core 20 legends remain bundled for 100% offline availability, while additional archives use a hybrid fetcher: when tapped, MoveLab checks local device storage (path_provider). If missing, it downloads the raw .pgn from GitHub/Cloud once, caches it to disk, and opens it in milliseconds on every subsequent launch.

Challenge 7: The "Works on Emulator, Breaks on Google Play Store" Trifecta

Every mobile developer eventually hits bugs that only happen in release builds (.aab / --release). MoveLab hit three of them right around launch:

1. The Silent Share Button Failure (RepaintBoundary & SharePlus)

  • Symptom: Tapping the "Share" button on the Player Style Card worked flawlessly on the Android emulator, but did nothing—or threw LateInitializationError: Local 'result' has not been initialized—when downloaded from the Google Play Store.
  • Fix: Replaced unstable SharePlus.instance.share calls and uninitialized late variables with deterministic RepaintBoundary image capture (toImage(pixelRatio: 3.0)), wrote the PNG bytes to getTemporaryDirectory(), and passed sharePositionOrigin via RenderBox coordinates so both Android and iPadOS/iOS sheets anchor and open reliably in release builds without showing a stuck loading overlay.

2. Lichess OAuth2 PKCE Redirect Dropping the Session

  • Symptom: Tapping "Sign in with Lichess" opened the Lichess OAuth page via flutter_web_auth_2. After clicking Authorize, the browser redirected back to com.movelab.app://—but either crashed or returned to the app without logging the user in.
  • Fix: Implemented the full OAuth2 PKCE (Proof Key for Code Exchange) handshake using crypto (sha256 challenge over a random code_verifier), properly registered the custom scheme callback intent filter in AndroidManifest.xml, exchanged the authorization code for an access token, queried https://lichess.org/api/account to retrieve the exact Lichess username/title, and automatically merged any locally saved guest matches into the user's Cloud Firestore library so no data was lost on login.

3. Audio Interrupting Spotify & Play Console AD_ID Rejections

  • Audio Focus: Every time a chess piece moved, audioplayers requested exclusive audio focus from Android/iOS, pausing the user's Spotify or YouTube Music! Configuring the global AudioContext with AudioContextAndroid(audioFocus: AndroidAudioFocus.none) / mixWithOthers allowed crisp move sounds to play right alongside background music.
  • Play Console AD_ID Error: Adding firebase_analytics caused the Android manifest merger to inject com.google.android.gms.permission.AD_ID, which triggered an immediate Google Play Console release blocker because our store declaration stated the app didn't use Advertising IDs for ads. Synchronizing the Play Console Advertising ID declaration with our analytics usage (or explicitly managing the permission in AndroidManifest.xml) resolved the release block.

💡 Key Takeaways for Fellow Flutter Developers

  1. Never trust linear lists for undo/redo or analysis: If users can step backward and explore alternatives, design your state as a Tree (Node + children) from day one.
  2. Always test RepaintBoundary and OAuth redirects in --release builds: ProGuard/R8 shrinking, manifest intent filters, and widget frame timing behave very differently outside debug mode.
  3. Hide serverless latency behind predictive caching: Pre-fetching batches in the background and setting an aggressive timeout (1.8s) with a local fallback turns a sluggish API into a 0ms instant experience.
  4. Build for the user's real environment: Small details—like not pausing their Spotify music when they play 1. e4, or keeping the chessboard from shifting by 38px while Stockfish calculates—are what separate a side project from a product players love using daily.

🔗 Try MoveLab & Explore the Code

Have you ever integrated a C/C++ engine like Stockfish or built custom board games in Flutter? I'd love to hear about your architectural approach and feedback in the comments!

Top comments (0)