Nine Men's Morris is a desktop implementation of the classic board game built with C++ and Qt Widgets. It supports local player-vs-player matches, player-vs-AI matches, saved games, configurable AI depth, and a full-screen graphical board.
The AI uses minimax search with fail-hard alpha-beta pruning. Game states are represented directly in C++ and the UI is built around Qt's signal/slot system.
- Local Player vs Player mode.
- Player vs AI mode.
- AI difficulty slider from depth 1 to depth 3.
- Player-first, AI-first, or randomized opening turn in Player vs AI mode.
- Three-phase Nine Men's Morris rules:
- placement while each player still has pieces to place;
- sliding to adjacent slots once all pieces are placed;
- flying to any empty slot when a player has only three pieces left.
- Mill detection and mandatory opponent-piece removal.
- Save, load, continue, and delete saved games.
- Persistent global settings in
src/config/global.ini. - Custom Qt stylesheet, fonts, cursor, and board textures.
- C++.
- Qt Widgets.
- qmake project file:
src/Nine_Mens_Morris.pro. - INI parsing through the included
INIReader/ini.csources.
The project was developed with a Qt 6 MinGW kit. The .pro file also includes the usual Qt 4+ widgets guard:
QT = core gui
greaterThan(QT_MAJOR_VERSION, 4): QT += widgets.
|-- README.md
|-- screenshots/
|-- src/
| |-- Nine_Mens_Morris.pro
| |-- main.cpp
| |-- mainwindow.*
| |-- gameboard.*
| |-- game.*
| |-- state.*
| |-- move.*
| |-- player.*
| |-- mainmenuwidget.*
| |-- playmenu.*
| |-- pausemenu.*
| |-- historywidget.*
| |-- settingswidget.*
| |-- gamecard.*
| |-- confirmationdialog.*
| |-- mainconfig.*
| |-- INIReader.* / ini.*
| |-- config/
| |-- fonts/
| |-- media/
| |-- savegames/
| `-- style/
The app is split into a small model layer, a Qt UI layer, and a simple persistence/config layer.
flowchart TD
App["main.cpp"] --> Window["MainWindow"]
Window --> Stack["QStackedWidget screens"]
Stack --> MainMenu["MainMenuWidget"]
Stack --> PlayMenu["PlayMenu"]
Stack --> Board["GameBoard"]
Stack --> Pause["PauseMenu"]
Stack --> History["HistoryWidget"]
Stack --> Settings["SettingsWidget"]
Board --> Game["Game"]
Board --> AI["AIPlayer"]
Game --> State["State"]
State --> Move["Move"]
AI --> State
History --> Saves["src/savegames/*.txt"]
Game --> Saves
Settings --> Config["src/config/global.ini"]
src/main.cpp creates the QApplication, loads the custom font, loads src/style/style.qss, installs the custom wooden cursor, creates MainWindow, and shows it full-screen.
MainWindow owns a QStackedWidget and switches between screens:
MainMenuWidget: Play, Settings, History, Quit.PlayMenu: game mode, AI difficulty, and first-player selection.GameBoard: the actual board, input handling, rendering, and pause button.PauseMenu: resume, restart, save, load, or return to the main menu.HistoryWidget: saved-game cards with continue/delete actions.SettingsWidget: sound and language controls.ConfirmationDialog: restart/quit confirmation prompts.
The widgets communicate through Qt signals and slots. For example, the play menu emits startButtonClicked(GameMode, int, int), which MainWindow::startGame receives and forwards into GameBoard::setGameSettings.
The model is centered on four classes:
Move: a compact command object containing source coordinates, destination coordinates, optional removal coordinates, and the color making the move. The sentinel coordinate3means "not applicable" for source/removal.State: the current board, piece counts, current turn, mill locking, win detection, child-state generation, evaluation, minimax, and alpha-beta pruning.Game: a wrapper around the currentState, the state history, move count, game mode, selected player color, difficulty, and save/load routines.Player,HumanPlayer,AIPlayer: player interfaces.AIPlayerasks the current state for the best next state and converts that state difference back into aMove.
The board is stored as:
Cell board[3][8];The first index is the ring:
0: outer square.1: middle square.2: inner square.
The second index is the position around that ring. The implementation numbers positions clockwise around each square:
0 -------- 1 -------- 2
| | |
7 | 3
| | |
6 -------- 5 -------- 4
Odd positions (1, 3, 5, 7) are the side midpoints. They are connected both around the ring and across rings. Even positions are square corners and only connect around their own ring.
Cells are stored with the Cell enum from src/init.hpp:
WHITE/BLACK: regular pieces.WHITE_LOCKED/BLACK_LOCKED: pieces currently part of a mill.EMPTY: empty slot.
The locked values let the engine distinguish pieces that are currently in a mill while still treating them as owned by the same player through State::isLocked.
GameBoard::mousePressEvent maps a click to board coordinates and then handles the current phase:
- Ignore input if the AI is thinking/playing or the game already has a winner.
- Build a
Movefor the current player. - In placement phase, place a piece on an empty slot.
- In movement phase, first select one of the current player's pieces, then select a valid destination.
- If a mill is formed, set
awaitRemoveand wait for the player to click an opponent piece. - Apply the move through
Game::makeMove. - Check for a winner.
- If the mode is Player vs AI, call
GameBoard::applyAIMove.
The game uses moveCount < 18 to determine the placement phase. Since each player has nine pieces, the first eighteen moves are placements.
After placement:
- A player with more than three pieces may slide only to adjacent empty slots.
- A player with exactly three pieces may fly to any empty slot.
The same movement rules are mirrored in State::createChildList, which is used by the AI to enumerate legal next states.
Mills are checked in two forms:
- Horizontal mills around a ring, starting from even positions.
- Vertical mills across rings, using odd positions.
When State::update applies a move, it first unlocks broken mills for the player who just moved, then locks newly created mills by converting pieces from WHITE/BLACK to WHITE_LOCKED/BLACK_LOCKED.
When a new mill is detected by the UI or generated by the AI search, the move includes an opponent piece to remove.
State::checkWin returns:
BLACKif white has only two pieces left.WHITEif black has only two pieces left.EMPTYif the game is still in placement, or if either player has exactly three pieces and can still fly.- Otherwise, the opponent wins if the current player has no legal adjacent movement.
AIPlayer::getNextMove calls:
State next = current.alphaBeta(diff, current.getTurn());The selected difficulty is used directly as the search depth. The returned best child state is compared against the current state with State::getMove to recover the concrete Move.
State::createChildList generates legal successors for the current turn:
- Placement phase: every empty slot is a possible destination.
- Flying phase: if the current player has three pieces left, every owned piece can move to any empty slot.
- Sliding phase: pieces can move only to adjacent empty slots.
- If a generated move forms a mill, the generator creates one child state for each removable opponent piece.
State::evaluate(Cell maxTurn) assigns:
- positive infinity if
maxTurnhas won; - negative infinity if the opponent has won;
- otherwise, a heuristic score based on:
- available mill lines for
maxTurn; - available mill lines for the opponent;
- material balance, weighted by
100 * pawnDifference.
- available mill lines for
In simplified form:
score = availableLines(maxTurn) - availableLines(opponent)
score += 100 * (pawnsLeft(maxTurn) - pawnsLeft(opponent))
State::alphaBeta is a fail-hard alpha-beta minimax search:
- maximizing levels keep the highest score found so far and prune when
value >= beta; - minimizing levels keep the lowest score found so far and prune when
value <= alpha; - leaf nodes and terminal states are evaluated with
State::evaluate.
The plain State::minMax implementation is still present for comparison/debugging, but the AI uses alphaBeta.
Saved games are stored under:
src/savegames/
Game::save writes a binary stream containing:
- game mode;
- player color;
- AI difficulty;
- move count;
- every
Statein the state history.
Game::load reads the same stream back, restores the latest state as the current board, checks the winner, and restores the turn.
Save names are generated as:
savegame_<saveCount>.txt
The counter comes from MainConfig.
Global settings live in:
src/config/global.ini
The current settings are:
language: currently stored and surfaced in the settings UI.sound: updated by the settings slider.saveCount: incremented whenever a new save is created.
MainConfig loads these values on startup and writes them back when the global config object is destroyed.
- Install Qt with a Desktop kit, for example Qt 6.x with MinGW on Windows.
- Open
src/Nine_Mens_Morris.proin Qt Creator. - Configure the project with your Desktop kit.
- Make sure the run working directory can resolve the runtime asset folders.
- Build and run.
The bootstrap code loads most assets relative to src:
./fonts/vinque-rg.otf
./style/style.qss
./media/wooden_cursor.png
./config/global.ini
./savegames/
Some board assets are currently referenced from GameBoard with ../../media/.... If the board texture or pause icon does not appear, adjust the Qt Creator working directory, copy the media folder next to the expected relative path, or normalize the asset paths in code before packaging.
Run these commands from the src directory after adding your Qt kit's qmake and compiler tools to PATH:
cd src
qmake Nine_Mens_Morris.pro
mingw32-makeThen launch the generated executable from a working directory that can see fonts, style, media, config, and savegames. Because a few board images use ../../media/..., verify the board texture and pause icon after launching.
The exact output directory depends on your Qt kit and qmake configuration. Qt Creator commonly creates a sibling or nested build directory named like:
build/Desktop_Qt_<version>_<compiler>-<configuration>/
- The application is path-sensitive. If assets fail to load, check the run working directory and the mixed
./media/../../mediareferences first. - The game currently uses qmake rather than CMake.
- The repository includes generated build artifacts under
src/build/; these are not required to understand or edit the source. - Saved games are binary despite the
.txtextension. - There is no automated test suite in the repository yet.
- The settings screen stores language and sound values, but language switching and audio playback are not fully implemented in the visible UI flow.
- Save files are binary dumps of in-memory structs, so they are best treated as version-specific and compiler-specific.
- The app is designed around full-screen desktop play and fixed board coordinates.
- Some debug logging remains in the implementation through
qDebugandstd::cout.
src/main.cpp: application bootstrap, font/style/cursor loading.src/mainwindow.*: screen ownership, navigation, and high-level actions.src/gameboard.*: board rendering, mouse interaction, game phase handling, and AI move application.src/game.*: current state, history, save/load, mode/difficulty/player settings.src/state.*: board state, legal move generation, mill locking, win detection, evaluation, minimax, alpha-beta pruning.src/move.*: move data object.src/player.*: human/AI player abstractions.src/mainconfig.*: INI-backed settings and save counter.src/historywidget.*andsrc/gamecard.*: save listing, load, and delete UI.src/playmenu.*,src/pausemenu.*,src/settingswidget.*,src/mainmenuwidget.*: menu widgets.

