2024 (WASM 2026)

STM32 TouchGFX Chess — Embedded C++ Firmware & Architecture

Chess engine, dynamic TouchGFX UI, Alpha-Beta Minimax AI, and FatFs microSD persistence built for the STM32H7B3I-DK microcontroller—playable live in the browser via a follow-up WebAssembly port.

C++ / CSTM32H7B3I-DKTouchGFXFreeRTOSMinimax AI (Alpha-Beta)RapidJSON & FatFsWebAssembly (Port)
STM32H7B3I-DK Discovery Kit
STM32H7B3I-DK Discovery Kit microcontroller board with 480x272 LCD screen
NRST // HARDWARE RESET

Embedded Chess on the STM32H7B3I-DK

This project is the story of two distinct engineering efforts:

  1. The Original Embedded STM32 Project (team-muppet/TouchGFX-Chess): Built by Anders Lillelund Clausen and Mathias Gorm Petersen in C and C++ for the STM32H7B3I-DK microcontroller board using STMicroelectronics’ TouchGFX framework and FreeRTOS. The case study below is based on our engineering report and architectural diagrams from that hardware project.
  2. The Browser WebAssembly Port (Nephim/ams-chess-wasm): A separate follow-up project where I used AI-assisted development to build an SDL2 + Emscripten compatibility shim so the unmodified C++ firmware can be played live in the hardware stage above. You can read the high-level overview of how the browser port works in the companion note: Bringing STM32 TouchGFX Chess to the Browser with WebAssembly.

STM32 Toolchain & Hardware Environment

Developing a graphical application with persistent storage on the STM32H7B3I-DK (480×272 capacitive touch screen, onboard microSD slot, LEDs, and integrated ST-Link debugger) relied on four coordinated tools in the STM32 ecosystem:

  • STM32CubeMX: Configures hardware peripherals, pin assignments, the 100 MHz SDMMC clock tree, and generates the underlying FreeRTOS and FATFS middleware initialization code.
  • TouchGFX Designer (4.24.0): Visual layout builder for the 480×272 display that generates C++ base classes (ChessGameViewBase) alongside a Visual Studio simulator project for rapid desktop UI debugging.
  • STM32CubeIDE & STM32CubeProg: Used to write the C/C++ firmware, flash the microcontroller, and perform live in-circuit JTAG debugging with hardware breakpoints over USB ST-Link.

Architectural Evolution & Breaking from TouchGFX MVP

TouchGFX applications are designed around a Model-View-Presenter (MVP) paradigm, where hardware/state logic lives in the Model, UI widgets live in the View, and the Presenter mediates between them.

Because we started designing our domain model from an object-oriented perspective before fully adopting TouchGFX’s MVP conventions, we initially encapsulated both game rules and rendering orchestration inside our own Board and polymorphic AbstractPiece hierarchy (GetColor(), GetType(), PotentialMoves(), and PossibleMoves()). While mixing a custom OOP rendering model with a framework built around MVP created friction early on, we iteratively refactored Board as the game grew in complexity—extracting dedicated, lower-coupling classes for board state, square rendering, move selection, animations, serialization, and AI:

ARCHITECTURE DIAGRAM // EVOLVED C++ CLASS HIERARCHY (REPORT FIG. 3 & 6) DECOUPLED RESPONSIBILITIES
ChessGameView : ChessGameViewBase TOUCHGFX VIEW

Entry point for TouchGFX screen lifecycle (setupScreen), 60Hz frame ticks (handleTickEvent), menu navigation, and touch coordinate dispatch.

ChessTimer & Snackbar UI UTILITIES

ChessTimer tracks per-player clock ticks; Snackbar animates transient status popups (e.g. "Check!") via quintEaseIn fade callbacks.

↓ delegates board interactions & game lifecycle to ↓
Board (Central Game Orchestrator) CORE CONTROLLER

Coordinates turn progression, piece selection, move execution, special rules (castling & promotion), AI turns, and save/load triggers across specialized subsystems:

RENDERING & UI OVERLAYS
BoardRenderer, SquareRenderer & PieceSelector
  • SquareRenderer: 64 dynamic 34×34 boxes (last-move & check colors)
  • BoardRenderer: Manages all 32 piece sprites
  • PieceSelector: Highlights selected piece & valid move markers
DOMAIN & POLYMORPHIC PIECES
BoardState & AbstractPiece ◁ PieceImpl[6]
  • PotentialMoves(): Raw geometric movement per piece type
  • PossibleMoves(): Filters moves that would leave King in check
  • BoardState: Evaluates check, checkmate & stalemate
AI & PERSISTENCE
ChessAI, GameStateSerializer & BoardStateModel
  • ChessAI: Recursive Minimax with Alpha-Beta pruning
  • GameStateSerializer: RapidJSON ↔ FatFs SD card I/O
  • BoardStateModel: Serializable snapshot of board & turn

1D Board Indexing (0..63), Z-Order Rendering & Snackbar Animations

To represent the 8×8 chessboard in C++, we chose a 1D array of 64 AbstractPiece* pointers (board[64]) rather than a 2D board[8][8] array, converting between (x, y) coordinates and linear indices 0..63 using integer division (/) and modulo (%) arithmetic.

On the physical 480×272 pixel TFT display, the chessboard occupies the left 272×272 pixel square (8 × 34 = 272 pixels), while the right 208×272 region houses the player clocks, turn indicator, and Save/Load controls. Because TouchGFX always renders the most recently added widget on top, the initialization order in Board::setupBoard() is critical:

DISPLAY Z-ORDER & 1D COORDINATE MAP // 480×272 LCD (REPORT FIG. 4, 7 & 8) 34×34 PX PER SQUARE
1D BOARD ARRAY (board[0..63]) 272×272 PX
0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
··· rows 2–6 (indices 16..55) ···
24
25
26
27
28
29
30
31
56
57
58
59
60
61
62
63
position = (x / 34) + (y / 34) * 8
TOUCHGFX Z-ORDER RENDERING STACK BOTTOM → TOP
Layer 1 (Bottom): SquareRenderer::initializeSquares()

Dynamically generates 64 34×34 pixel Box widgets instead of a static board image so individual squares can recolor on the fly (green for last move origin/target, red/amber for Check).

Layer 2: BoardRenderer::setupBoard()

Instantiates all 32 polymorphic PieceImpl sprites above the square boxes.

Layer 3: PieceSelector::initialize()

Renders a light-blue highlight on the active piece, small gray markers on valid PossibleMoves() squares, and capture indicators on threatened enemy pieces.

Layer 4 (Top): Snackbar::displaySnackbar()

Chains TouchGFX startFadeAnimation(255, fadeIn, quintEaseIn) → setFadeAnimationDelay(visibleDuration) → fade-out to 0 → ParentContainer.remove(Image) to display transient popups like "Check!".

Touch input is captured by a single ClickListener attached to the root Background widget. Inside , touch release events with e.getX() < 272 are integer-divided by 34 to map the (x, y) pixel coordinate directly to 0..63. Meanwhile, is invoked by TouchGFX on every display refresh (60 Hz), driving _chessTimer while a match is active.

Nephim/ams-chess-wasm / gui/src/chessgame_screen/ChessGameView.cpp L51–L91
GitHub
Highlights:
51 void ChessGameView::handleTickEvent()
52 {
53 if (currentState == GameState::GAME)
54 {
55 _chessTimer.tick();
56 }
57 }
58
59 void ChessGameView::DifficultyValueChanged(int value)
60 {
61 aiDepth = value;
62 }
63
64 void ChessGameView::WinScreenButton()
65 {
66 WinScreen.hide();
67 switchToMenu();
68 }
69
70 void ChessGameView::screenClickedHandler(const Image& i, const ClickEvent& e)
71 {
72 if (&i != &Background)
73 {
74 return;
75 }
76
77 if (e.getType() != touchgfx::ClickEvent::ClickEventType::RELEASED)
78 {
79 return;
80 }
81
82 // Debug section
83 Unicode::snprintf(locationTextBoxBuffer, LOCATIONTEXTBOX_SIZE, "%c%d", e.getX() / 34 + 65, 8 - e.getY() / 34);
84 locationTextBox.invalidate();
85
86 if (e.getX() < 272)
87 {
88 int position = e.getX() / 34 + (e.getY() / 34) * 8;
89 _chessboard.handleClickEvent(position);
90 }
91 }

Castling (“Rokade”) — A Case Study in Architectural Trade-Offs

In our initial design, every chess move was modeled as a single AbstractPiece moving from from to to. Implementing Castling (Rokade) served as a practical case study in how complex domain rules challenge an existing software architecture: castling moves both the King and a Rook in a single turn (relocating the King two squares left or right and jumping the Rook to the adjacent square), subject to three strict invariants:

  1. Movement History (Krav 1): Neither the King nor the involved Rook may have moved previously in the match. Rather than performing runtime type checks on every move, we added hasMoved() directly to AbstractPiece and flipped it to true after any completed move.
  2. Clear Path (Krav 2): Since only the King initiates castling in our UI, a dedicated check in King verifies that neither the King nor the target Rook has moved and that all intermediate squares between them are empty before adding the castling target square to PossibleMoves().
  3. Check Traversal Validation (Krav 3): In our architecture, BoardState is responsible for verifying whether a move leaves the player’s King in check. For castling, this validation had to be extended so BoardState verifies that the King is neither in check on its starting square nor passes through check on either of the two squares it traverses.

When all three conditions pass and the player selects the castling square, Board::handleClickEvent detects the castling move, relocates the King, and triggers a secondary moveRook(rookFrom, rookTo) update before refreshing SquareRenderer colors. Supporting Castling required coordinated changes across four classes (AbstractPiece, King, Board, and BoardState)—demonstrating why features like en passant and underpromotion were consciously descoped under our MoSCoW prioritization in favor of singleplayer AI and SD-card persistence.


Singleplayer Chess AI: Recursive Minimax with Alpha-Beta Pruning

To allow playing solo against the STM32H7 microcontroller instead of requiring two human players, I implemented ChessAI using a recursive Minimax decision tree optimized with Alpha-Beta pruning (configurable via the in-game difficulty slider).

At each ply down to searchDepth, the algorithm simulates all legal moves returned by getAllPossibleMoves(boardState, currentPlayer), alternating between the Maximizing player (the AI, which seeks to maximize the board evaluation score) and the Minimizing player (the human opponent, assumed to play the move that minimizes the AI’s score):

ALGORITHM DIAGRAM // MINIMAX TREE WITH ALPHA-BETA PRUNING (REPORT FIG. 15 & 16) COMPLEXITY: O(b^d) → O(√(b^d)) BEST CASE
MINIMAX SEARCH & ALPHA-BETA CUTOFF DEPTH-LIMITED
Ply 0 (MAX / AI) Root Chosen Value: -7
Ply 1 (MIN / Human)
min(10, -10) = -10
Ply 1 (MIN / Human)
min(5, -7) = -7 ★
Ply 2 (MAX)
10
Ply 2 (MAX)
-10
✂ β ≤ α cutoff
Ply 2 (MAX)
5
Ply 2 (MAX)
-7
✂ β ≤ α cutoff
EXECUTION FLOW (REPORT FIG. 16) SINGLEPLAYER TURN
  1. User Move: Board::makeMove(from, to) applies the human player's move to BoardState.
  2. AI Dispatch: Board invokes ChessAI::getBestMove(state).
  3. Tree Search: ChessAI enumerates getAllPossibleMoves(color), clones BoardState, updates King indices, and recurses into minimax(state, depth, alpha, beta, isMaximizing).
  4. State Commit: Returns bestMove(from, to) to Board, which updates BoardState, refreshes SquareRenderer colors, advances the active player, and checks for check/checkmate.

In , leaf states at depth == 0 are scored by summing material weights across all 64 squares (Pawn = 100, Knight / Bishop = 300, Rook = 500, Queen = 900, King = 10000), adding values for aiColor pieces and subtracting values for opponent pieces. During the recursive traversal in , whenever beta <= alpha, remaining sibling moves are immediately pruned (break), reducing the best-case time complexity from O(bd) to O(√bd).

Nephim/ams-chess-wasm / gui/src/chessgame_screen/ChessAI.cpp L42–L94
GitHub
Highlights:
42 int ChessAI::evaluateBoard(BoardState& boardState) {
43 // A simple evaluation function: sum of piece values
44 int pieceValues[6] = { 100, 300, 300, 500, 900, 10000 };
45 int evaluation = 0;
46
47 for (const auto& piece : boardState.getBoard()) {
48 if (piece) {
49 int value = pieceValues[static_cast<int>(piece->GetType())];
50 evaluation += (piece->GetColor() == aiColor) ? value : -value;
51 }
52 }
53
54 return evaluation;
55 }
56
57 int ChessAI::minimax(BoardState& boardState, int depth, int alpha, int beta, bool isMaximizingPlayer) {
58 if (depth == 0) {
59 return evaluateBoard(boardState);
60 }
61
62 PieceColor currentPlayer = isMaximizingPlayer ? aiColor : (aiColor == PieceColor::WHITE ? PieceColor::BLACK : PieceColor::WHITE);
63 std::list<std::pair<int, int>> possibleMoves = getAllPossibleMoves(boardState, currentPlayer);
64
65 if (possibleMoves.empty()) {
66 return isMaximizingPlayer ? std::numeric_limits<int>::min() : std::numeric_limits<int>::max();
67 }
68
69 if (isMaximizingPlayer) {
70 int maxEval = std::numeric_limits<int>::min();
71 for (const auto& move : possibleMoves) {
72 BoardState newState = boardState;
73 newState.getBoard()[move.second] = std::move(newState.getBoard()[move.first]);
74 newState.getBoard()[move.first] = nullptr;
75
76 // Ensure king position updates if a king is moved
77 if (newState.getBoard()[move.second] && newState.getBoard()[move.second]->GetType() == PieceType::KING) {
78 if (newState.getBoard()[move.second]->GetColor() == PieceColor::WHITE) {
79 newState.setWhiteKingPosition(move.second);
80 }
81 else {
82 newState.setBlackKingPosition(move.second);
83 }
84 }
85
86 int eval = minimax(newState, depth - 1, alpha, beta, false);
87 maxEval = std::max(maxEval, eval);
88 alpha = std::max(alpha, eval);
89 if (beta <= alpha) {
90 break;
91 }
92 }
93 return maxEval;
94 }

Persisting Game State: RapidJSON, FatFs SDMMC1 & JTAG Debugging

To let players save a match to a physical microSD card (CN4 slot on the STM32H7B3I-DK) and resume it later, GameStateSerializer serializes the 64-square board state and active player color into a JSON string (GAME.TXT) using RapidJSON. Because RapidJSON is a header-only C++ library, we could include it cleanly across both the STM32CubeIDE ARM GCC Makefiles and the TouchGFX Visual Studio simulator project without cross-compiling external static libraries.

PERSISTENCE PIPELINE // RAPIDJSON + FATFS + SDMMC1 (REPORT FIG. 18–25) 4-BIT WIDE BUS @ 100 MHZ
1. C++ SERIALIZER
GameStateSerializer
  • Snapshots 64 squares & currentPlayer into BoardStateModel
  • Serializes/deserializes JSON via header-only RapidJSON
  • In-memory fallback when debugging in Visual Studio simulator
extern "C" → fatfs.c
2. FATFS & FREERTOS
WriteToSDCARD / ReadFromSDCARD
  • Reads and writes GAME.TXT via FatFs (f_open, f_write, f_read)
  • Configured across 3 FreeRTOS tasks in CubeMX
  • Diagnosed FR_DISK_ERR via ST-Link JTAG breakpoints
SDMMC1 → CN4 uSD
3. STM32H7 HARDWARE
SDMMC1 & Cortex-M7 DCache
  • SDMMC1 4-bit Wide Bus clocked at 100 MHz (PLL1Q)
  • GPIO PI8 card-detect pin configured as Input Pull-Up
  • Fix: Disabled Cortex-M7 DCache to prevent DMA cache coherency errors

Hardware Debugging Highlight: Solving FR_DISK_ERR via JTAG & Cortex-M7 DCache

Integrating the microSD reader into the multi-tasking FreeRTOS + TouchGFX project produced one of our most interesting embedded debugging challenges:

  1. CubeMX Peripheral Setup: Following the STM32H7B3I-DK schematic (UM2569, Table 14), we configured SDMMC1 in SD 4-bits Wide bus mode (PC8..PC12, PD2) clocked at 100 MHz via PLL1Q, enabled the SDMMC1 global interrupt in NVIC, and mapped the hardware card-detect pin (uSD_Detect on GPIO PI8) to Input mode with an internal Pull-up resistor.
  2. Isolating the Failure with JTAG: While a standalone single-task test project could read and write to the microSD card without issue, calling the exact same FatFs code from our TouchGFX project’s StartDefaultTask silently failed. Stepping through f_open in ff.c using ST-Link JTAG hardware breakpoints in STM32CubeIDE revealed that find_volume was failing with FR_DISK_ERR on the SDFile struct at 0x240035e0.
  3. Root Cause (Cortex-M7 L1 Data Cache): On the STM32H7 Cortex-M7 architecture, L1 Data Cache (DCache) coherency conflicts with SDMMC transfers when writing buffers from cached AXI SRAM. Disabling DCache for SD card operations resolved FR_DISK_ERR immediately, allowing matches to be saved to GAME.TXT on the physical microSD card and resumed across power cycles.

Companion Note: How the Browser WebAssembly Port Works

FOLLOW-UP PROJECT // AI-ASSISTED WEBASSEMBLY PORT

Bringing STM32 TouchGFX Chess to the Browser with WebAssembly

Read the separate high-level write-up explaining how I used AI-assisted development to build a C++ SDL2/WebGL compatibility shim and compile our unmodified STM32 C++ chess code to WebAssembly.

Read High-Level WASM Note →