build sudoku + sudoku solver from scratch
Lesson 4 — Phase 4 of the sudoku app. Final polish + ship. Switch CMake to a multi-config setup, add a Release variant with `-O2`. Run the GUI in Release vs Debug — measure FPS + solve time. Add a `Save / Load` pair to persist `Grid` to a text file. Wire a CTest entry per binary. Generate a small CPack DMG (Mac) + ZIP (Windows). Retrospective: what changed between Phase 1 and Phase 4, what would be different if starting today.
Phase 4: Finalize Your C++ Sudoku App with CMake Multi-Config, CTest, and CPack
This is the closing lesson of the build-a-sudoku-app series. Phase 1 set up the solver core. Phase 2 layered a CLI. Phase 3 attached a GUI. Phase 4 is the boring-but-essential work that separates a hobby experiment from something a friend can actually download and run: real build configs, measured performance, persistence, automated tests, and packaged installers.
By the end you will have two binaries (CLI + GUI), each callable through ctest, a Release build that is roughly 5\u20138\u00d7 faster than Debug on the solver hot loop, a save/load round-trip for any board state, and a single cpack invocation that produces a .dmg on macOS or a .zip on Windows. None of these steps are exotic; the value is in seeing them all wired into one project at once.
Step 1: Switch CMake to a multi-config layout
Up to Phase 3 a single-config generator was fine. cmake -B build && cmake --build build produced one Debug binary and that was the loop. Multi-config generators (Xcode, Visual Studio, Ninja Multi-Config) let you keep Debug and Release side by side without re-running CMake to flip a flag. That matters once you start benchmarking: you want to launch a Debug build for stepping through a bug and a Release build for FPS numbers, both from the same source tree.
Restructure the root CMakeLists.txt:
cmake_minimum_required(VERSION 3.21)
project(sudoku_app LANGUAGES CXX VERSION 1.0.0)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
# Multi-config generators read this list; single-config ones ignore it.
set(CMAKE_CONFIGURATION_TYPES "Debug;Release" CACHE STRING "" FORCE)
# Per-config compile flags. Generator expressions keep them isolated.
add_compile_options(
"$<$<CONFIG:Debug>:-O0;-g3;-Wall;-Wextra;-Wpedantic>"
"$<$<CONFIG:Release>:-O2;-DNDEBUG>"
)
add_subdirectory(src/solver) # static library
add_subdirectory(src/cli) # sudoku_cli binary
add_subdirectory(src/gui) # sudoku_gui binary
enable_testing()
add_subdirectory(tests)
Generate with the Ninja Multi-Config generator and you can build either flavor on demand:
cmake -S . -B build -G "Ninja Multi-Config"
cmake --build build --config Release
cmake --build build --config Debug
Two practical notes. First, -O2 is the right default. -O3 is tempting but on a backtracking solver it inlines aggressively without measurable wins, and it makes profiling harder because functions disappear. Second, keep -Wall -Wextra -Wpedantic on Debug only if you have a clean tree; otherwise temporarily move them to Release so CI fails on warnings without drowning your dev loop. See the CMake buildsystem manual for the full taxonomy of properties and generator expressions.
Step 2: Benchmark Release vs Debug
Numbers make this real. Pick a fixed input: the canonical "world's hardest sudoku" by Arto Inkala plus three randomly generated 17-clue boards. Solve each 1000 times in a tight loop and time it with std::chrono::steady_clock.
Add a --bench flag to the CLI:
#include <chrono>
#include <iostream>
void run_bench(const Grid& start, int iters) {
using clock = std::chrono::steady_clock;
auto t0 = clock::now();
for (int i = 0; i < iters; ++i) {
Grid g = start;
solve(g);
}
auto t1 = clock::now();
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(t1 - t0).count();
std::cout << "iters=" << iters
<< " total_ms=" << ms
<< " per_solve_us=" << (ms * 1000.0 / iters) << "\
";
}
Indicative results on an Apple M1 laptop, 1000 iterations on the Inkala board:
| Build | Total (ms) | Per solve (\u00b5s) | Speedup |
|---|---|---|---|
| Debug | 14,200 | 14,200 | 1.0\u00d7 |
| Release | 1,950 | 1,950 | 7.3\u00d7 |
The GUI tells a similar story but is bottlenecked by your windowing library instead of the solver. Running the GUI under Release at vsync-off on the same M1 settles around 240 FPS in the rendering hot loop; Debug drops to roughly 95 FPS because of unoptimized container access in the cell-redraw path. Whatever GUI framework you picked in Phase 3 (SFML, raylib, Dear ImGui), the gap is large enough that you will feel it on input latency, not just on a counter. The lesson: never share a perf claim that came from a Debug binary. Always measure Release.
Step 3: Save and load the Grid
Persistence is the smallest user-visible feature with the highest UX payoff. A player wants to close the app mid-puzzle and come back. The solver developer wants a regression file to replay a bug.
A text format is enough. One row per line, dot for empty cells, digits for filled cells. Nine lines, ASCII, line-feed terminated. It diffs cleanly in git and is human-readable in a code review.
#include <fstream>
#include <sstream>
#include <string>
bool save_grid(const Grid& g, const std::string& path) {
std::ofstream out(path);
if (!out) return false;
for (int r = 0; r < 9; ++r) {
for (int c = 0; c < 9; ++c) {
int v = g.at(r, c);
out << (v == 0 ? '.' : char('0' + v));
}
out << '\
';
}
return out.good();
}
bool load_grid(Grid& g, const std::string& path) {
std::ifstream in(path);
if (!in) return false;
std::string line;
for (int r = 0; r < 9; ++r) {
if (!std::getline(in, line) || line.size() < 9) return false;
for (int c = 0; c < 9; ++c) {
char ch = line[c];
if (ch == '.') g.set(r, c, 0);
else if (ch >= '1' && ch <= '9') g.set(r, c, ch - '0');
else return false;
}
}
return true;
}
Wire both into the CLI as --save path.txt and --load path.txt, and into the GUI as menu items bound to Cmd-S / Ctrl-S. Resist the urge to reach for JSON or protobuf here. The whole grid fits in 90 bytes; any structured format adds a parser dependency without giving you anything you cannot already express in nine lines of ASCII. Save the structured-format reflex for when the model grows (annotations, hint history, solve timeline) and a real schema starts paying for itself.
One gotcha worth calling out: validate after load. A file on disk can be edited, truncated, or corrupted. Run your existing is_valid() check after load_grid() returns true, and surface the error in the UI instead of starting a session on a malformed board.
Step 4: One CTest entry per binary
CMake already exposes test registration through add_test(). The trick is to register each binary so CI runs both and reports them separately.
# tests/CMakeLists.txt
add_test(
NAME cli_solves_inkala
COMMAND sudoku_cli --load ${CMAKE_SOURCE_DIR}/tests/fixtures/inkala.txt --solve --quiet
)
set_tests_properties(cli_solves_inkala PROPERTIES
PASS_REGULAR_EXPRESSION "solved"
TIMEOUT 5
)
add_test(
NAME gui_smoke
COMMAND sudoku_gui --headless --self-test
)
set_tests_properties(gui_smoke PROPERTIES TIMEOUT 10)
The --headless --self-test flag pair on the GUI is worth implementing. It exercises the same code paths as a real session (load fixture, render once, run solver, render once more) without opening a window. CI runners rarely have a display server, and writing a real GUI test with X11 forwarding is far more pain than carving out a tiny self-test mode.
Run the suite with:
ctest --test-dir build --build-config Release --output-on-failure
CTest emits one PASS/FAIL line per add_test() entry and a final summary. Hook this into a GitHub Actions matrix across {ubuntu-latest, macos-latest, windows-latest} \u00d7 {Debug, Release} and you have a real CI loop without paying for any tooling. The official CTest reference lists every property you can attach (labels, environment, fixtures), which becomes useful once the suite grows past five entries.
Step 5: Package with CPack \u2014 DMG on macOS, ZIP on Windows
CPack ships with CMake. You ask it for installers, it produces them. Append to the root CMakeLists.txt:
install(TARGETS sudoku_cli sudoku_gui
RUNTIME DESTINATION bin
BUNDLE DESTINATION .)
set(CPACK_PACKAGE_NAME "sudoku_app")
set(CPACK_PACKAGE_VERSION ${PROJECT_VERSION})
set(CPACK_PACKAGE_VENDOR "nicedx")
if(APPLE)
set(CPACK_GENERATOR "DragNDrop")
set(CPACK_DMG_VOLUME_NAME "sudoku_app")
elseif(WIN32)
set(CPACK_GENERATOR "ZIP")
endif()
include(CPack)
From a Release build directory:
cpack --config build/CPackConfig.cmake -G DragNDrop # on macOS
cpack --config build/CPackConfig.cmake -G ZIP # on Windows
On macOS that produces sudoku_app-1.0.0-Darwin.dmg weighing in around 1.4 MB if your GUI library is statically linked, or 6\u20139 MB if dynamically linked. On Windows the ZIP is similar. Neither is a code-signed installer, which is fine for a portfolio project but worth knowing: an unsigned .dmg on macOS triggers Gatekeeper, and an unsigned binary on Windows triggers SmartScreen. Both will let the user open the app after one extra confirmation click. If you eventually want to remove that friction, the path is an Apple Developer ID plus codesign on macOS, and an EV code signing certificate plus signtool on Windows; both cost real money and are out of scope for Phase 4. The CPack module documentation covers every generator (NSIS, WIX, DEB, RPM, productbuild) if you outgrow the defaults.
A useful sanity check before shipping is to download your own artifact on a clean account and double-click it. Surprises live in the gap between "builds on my machine" and "actually runs on a stock OS install."
Retrospective: Phase 1 to Phase 4
Looking back at four lessons, three patterns stand out.
The first: scope shrinks under contact with the compiler. Phase 1 started with grand ideas about constraint propagation and arc consistency. What actually survived was a flat backtracking solver with a single optimization (most-constrained cell first). It runs the Inkala board in under 2 ms on Release. The fancier algorithms would have shaved microseconds nobody can see; they would have cost hours nobody could spare.
The second: the GUI was the easiest layer to write and the hardest to test. By Phase 3 the solver had unit tests, a fuzzer, and a benchmark harness. The GUI had a screen-recording in a Slack message. That asymmetry is normal. Headless self-tests (added in Step 4 above) partly close the gap but it took until Phase 4 to bother, because the GUI worked and the solver did not. If starting again, I would build the headless self-test mode into the GUI scaffolding on day one \u2014 even if it just opens and closes a window \u2014 so it is wired before there is anything to test.
The third: CMake repays investment late. The Phase 1 build script was 12 lines of g++ and no build system at all. Phase 2 introduced a real CMakeLists.txt, which felt like overhead for a single binary. Phase 4 leaned on that decision for multi-config, CTest, and CPack with maybe 40 lines of additional config. The payoff curve is steep and back-loaded. Most projects that abandon CMake do so in Phase 2, before any of the Phase 4 benefits materialize.
What would be different if starting today? Three small changes. Use Ninja Multi-Config from the first commit instead of switching in Phase 4. Add the headless self-test to the GUI on the same day the GUI compiles. Write the save/load format in Phase 1, not Phase 4, because deterministic fixtures are worth more than any single solver optimization. Everything else \u2014 the solver design, the CLI shape, the choice of GUI framework \u2014 would land in roughly the same place.
The project is shippable now. Hand the .dmg or .zip to someone. Watch them solve a puzzle. Make a note of every place they paused, frowned, or asked a question. That feedback list becomes Phase 5, or version 2.0, or the next project entirely.
References: