Phase 1: Grid type + UI 9x9
Build the Sudoku app shell with a 9x9 ImGui table, a Grid type backed by std::array, a FixedMap for puzzle givens, and an App class with RAII GLFW lifecycle.
Lesson 1 — Phase 1: Grid type + 9x9 UI
Commit:
2c97016(partial). Phase 1 of the sudoku app.
1. Goals
After this lesson you will have:
- A Sudoku app with a 720x800 window, titled "Sudoku"
- A
Gridtype —std::array<std::array<int, 9>, 9>holding the puzzle - A
FixedMap— a 9x9 bool marking the fixed cells (from the original puzzle) - An
Appclass using the RAII pattern (GLFW init/shutdown) - A
UIPanelclass separating the view from the controller - A 9x9 ImGui Table render: fixed cells in white, editable cells accepting numeric input
- 3x3 boxes distinguished by a checkerboard background color
2. File tree
apps/sudoku/ ← NEW in its entirety
├── CMakeLists.txt ← NEW
├── .vscode/
│ ├── launch.json ← NEW: debug config
│ ├── settings.json ← NEW: cmake path
│ └── tasks.json ← NEW
└── src/
├── main.cpp ← NEW: entry point
├── app.h ← NEW: App class declaration
├── app.cpp ← NEW: App lifecycle
├── ui_panel.h ← NEW: UIPanel class
├── ui_panel.cpp ← NEW: render grid
├── grid.h ← NEW (Phase 1, later migrated in Phase 3.0)
└── grid.cpp ← NEW (Phase 1, later migrated in Phase 3.0)
In Phase 3.0 we will migrate
grid.h/cpptolibs/solver/. This lesson keeps the code inapps/sudoku/src/.
3. Content to learn
- 2D
std::array— fixed-size compile-time container usingalias — C++11 type alias (vstypedef)constexpr— type-safe compile-time constantextern const— declaration/definition separation to avoid ODR violation= default,= delete— explicit default + non-copyable- In-class member initializer — initialize a field at the declaration site
- Forward declaration — reduce include weight
- Game loop pattern — immediate-mode rendering
- ImGui Table API — BeginTable, TableSetupColumn, TableNextRow/Column
PushID/PopIDstack — unique widget IDs
4. How to do it
Step 1 — App class declaration (app.h)
File: apps/sudoku/src/app.h
#pragma once
#include "grid.h" // Phase 1; will change to "solver/grid.h" in Phase 3.0
#include "ui_panel.h"
#include <memory>
#include <vector>
struct GLFWwindow; // forward declaration — do not include the GLFW header
class App {
public:
App();
~App();
App(const App&) = delete;
App& operator=(const App&) = delete;
int run();
private:
bool init();
bool shutdown();
void draw_frame();
GLFWwindow* window_ = nullptr;
Grid grid_;
FixedMap fixed_;
UIPanel panel_;
};
Explanation:
- Forward declaration
struct GLFWwindow;: the GLFW header is ~3000 lines. A file includingapp.hdoes not need to know the struct internals, it only usesGLFWwindow*(the pointer size is known without the definition). - Non-copyable: App manages GLFW resources (window, OpenGL context). Copying = two instances holding the same pointer → double-free when both destruct.
= deleteprevents the compiler from synthesizing the copy operations. - In-class initialization
GLFWwindow* window_ = nullptr;: guarantees the pointer is default-initialized. No need to remember initializing it in every constructor.
Step 2 — Grid type (grid.h + grid.cpp)
File: apps/sudoku/src/grid.h
#pragma once
#include <array>
constexpr int N = 9;
using Grid = std::array<std::array<int, N>, N>;
using FixedMap = std::array<std::array<bool, N>, N>;
extern const char* SAMPLE_PUZZLE;
Grid load_puzzle(const char* s);
FixedMap make_fixed_map(const Grid& g);
File: apps/sudoku/src/grid.cpp
#include "grid.h"
const char* SAMPLE_PUZZLE = "53..7...."
"6..195..."
".98....6."
"8...6...3"
"4..8.3..1"
"7...2...6"
".6....28."
"...419..5"
"....8..79";
Grid load_puzzle(const char* s) {
Grid g{};
for (int r = 0; r < N; ++r) {
for (int c = 0; c < N; ++c) {
char ch = s[r * N + c];
g[r][c] = (ch == '.') ? 0 : (ch - '0');
}
}
return g;
}
FixedMap make_fixed_map(const Grid& g) {
FixedMap m{};
for (int r = 0; r < N; ++r) {
for (int c = 0; c < N; ++c) {
m[r][c] = (g[r][c] != 0);
}
}
return m;
}
Explanation:
std::array<std::array<int, N>, N>: a 2D array with compile-time fixed size. 324 bytes (994). Stack-allocated, cheap to copy/move.Grid g{};value-initialization: every int = 0. Do not writeGrid g;— uninitialized, undefined behavior on read.- String literal concatenation: nine adjacent strings, the compiler joins them into a single 81-character string. Pretty + easy to read.
extern const char* SAMPLE_PUZZLE;declaration-only in the header. Defined exactly once in the .cpp. If defined in the header → every including file would carry a copy → linker error "multiple definition".
Step 3 — UIPanel class (ui_panel.h + ui_panel.cpp)
File: apps/sudoku/src/ui_panel.h
#pragma once
#include "grid.h"
class UIPanel {
public:
void draw(Grid& grid, const FixedMap& fixed);
};
Phase 2+ will add a
validatorsparameter. Phase 3.0.1 will add adraw_controlsmethod. Phase 1 has only this.
File: apps/sudoku/src/ui_panel.cpp
#include "ui_panel.h"
#include "imgui.h"
void UIPanel::draw(Grid& grid, const FixedMap& fixed) {
if (ImGui::BeginTable("grid", N,
ImGuiTableFlags_Borders | ImGuiTableFlags_SizingFixedFit)) {
for (int i = 0; i < N; ++i) {
ImGui::TableSetupColumn("", ImGuiTableColumnFlags_WidthFixed, 40.0f);
}
for (int r = 0; r < N; ++r) {
ImGui::TableNextRow();
for (int c = 0; c < N; ++c) {
ImGui::TableNextColumn();
// 3x3 box checkerboard
int box_r = r / 3, box_c = c / 3;
if ((box_r + box_c) % 2 == 1) {
ImGui::TableSetBgColor(ImGuiTableBgTarget_CellBg,
IM_COL32(40, 40, 40, 255));
}
if (fixed[r][c]) {
ImGui::Text("%d", grid[r][c]);
} else {
char buf[2] = "";
if (grid[r][c] != 0) buf[0] = '0' + grid[r][c];
buf[1] = '\0';
ImGui::PushID(r * N + c);
ImGui::SetNextItemWidth(34.0f);
ImGui::InputText("##cell", buf, sizeof(buf),
ImGuiInputTextFlags_CharsDecimal);
ImGui::PopID();
if (buf[0] >= '1' && buf[0] <= '9')
grid[r][c] = buf[0] - '0';
else
grid[r][c] = 0;
}
}
}
ImGui::EndTable();
}
}
Explanation:
BeginTable("grid", N, flags): creates a 9-column table. Returns true → draw the content; you must callEndTable()even when it is false.TableSetupColumn("", FixedWidth, 40.0f): column width fixed at 40px, called once per column before drawing rows.- 3x3 checkerboard:
box_r + box_ceven/odd produces the pattern. The corner box (0,0) is even (light), (0,1) is odd (dark), and so on. This creates the perception of 9 separate boxes. PushID(r * N + c): all 81 cells share the same label"##cell". ImGui uses the label as the ID — colliding IDs → ImGui crashes or focus state goes to the wrong cell. Push a unique ID (0..80) before the widget, Pop it right after.char buf[2]: one digit char + one null terminator. InputText modifies buf in place.ImGuiInputTextFlags_CharsDecimal: input filter — only allows the digits 0-9.
Step 4 — App lifecycle (app.cpp)
File: apps/sudoku/src/app.cpp
#include "app.h"
#include "imgui.h"
#include "imgui_impl_glfw.h"
#include "imgui_impl_opengl3.h"
#define GL_SILENCE_DEPRECATION
#include <GLFW/glfw3.h>
#include <cstdio>
static void glfw_error_callback(int error, const char* description) {
std::fprintf(stderr, "GLFW error %d: %s\n", error, description);
}
App::App() = default;
App::~App() { shutdown(); }
bool App::init() {
glfwSetErrorCallback(glfw_error_callback);
if (!glfwInit()) return false;
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 2);
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GL_TRUE);
window_ = glfwCreateWindow(720, 800, "Sudoku", nullptr, nullptr);
if (!window_) {
glfwTerminate();
return false;
}
glfwMakeContextCurrent(window_);
glfwSwapInterval(1);
IMGUI_CHECKVERSION();
ImGui::CreateContext();
ImGui::StyleColorsDark();
ImGui_ImplGlfw_InitForOpenGL(window_, true);
ImGui_ImplOpenGL3_Init("#version 150");
grid_ = load_puzzle(SAMPLE_PUZZLE);
fixed_ = make_fixed_map(grid_);
return true;
}
bool App::shutdown() {
if (window_) {
ImGui_ImplOpenGL3_Shutdown();
ImGui_ImplGlfw_Shutdown();
ImGui::DestroyContext();
glfwDestroyWindow(window_);
glfwTerminate();
window_ = nullptr;
}
return true;
}
void App::draw_frame() {
ImGui::Begin("Sudoku");
panel_.draw(grid_, fixed_);
ImGui::End();
}
int App::run() {
if (!init()) return 1;
while (!glfwWindowShouldClose(window_)) {
glfwPollEvents();
ImGui_ImplOpenGL3_NewFrame();
ImGui_ImplGlfw_NewFrame();
ImGui::NewFrame();
draw_frame();
ImGui::Render();
int w, h;
glfwGetFramebufferSize(window_, &w, &h);
glViewport(0, 0, w, h);
glClearColor(0.08f, 0.09f, 0.10f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData());
glfwSwapBuffers(window_);
}
return 0;
}
Explanation of key parts:
static void glfw_error_callback(...)withstaticat file scope = internal linkage. The function exists only in this translation unit, avoiding an ODR violation if another file defines a function with the same name.#define GL_SILENCE_DEPRECATIONbefore#include <GLFW/glfw3.h>: silences Apple's deprecation warning about OpenGL (macOS has moved to Metal).- Idempotent
shutdown(): theif (window_)guard allows it to be called multiple times without crashing. The destructor also callsshutdown()→ safe if the user happened to call it already before destruction. - Game loop inside
run(): 4 steps per frame:- Poll input (
glfwPollEvents) - ImGui new frame (3 calls — they must follow the backend order exactly)
- Build the UI tree (
draw_frame) - Render → OpenGL → swap buffers
- Poll input (
Step 5 — Entry point (main.cpp)
File: apps/sudoku/src/main.cpp
#include "app.h"
int main() {
App app;
return app.run();
}
Explanation:
- 7 lines, with all the logic in
App::run(). App app;lives on the stack → whenmainreturns, the destructor is called automatically →shutdown()cleans up resources.- The return value of
run()= the process exit code.
Step 6 — CMakeLists (CMakeLists.txt)
File: apps/sudoku/CMakeLists.txt
cmake_minimum_required(VERSION 3.20)
project(sudoku CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
if(NOT CMAKE_BUILD_TYPE)
set(CMAKE_BUILD_TYPE Debug)
endif()
include(FetchContent)
# GLFW 3.4
set(GLFW_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)
set(GLFW_BUILD_TESTS OFF CACHE BOOL "" FORCE)
set(GLFW_BUILD_DOCS OFF CACHE BOOL "" FORCE)
FetchContent_Declare(glfw
GIT_REPOSITORY https://github.com/glfw/glfw.git
GIT_TAG 3.4)
FetchContent_MakeAvailable(glfw)
# Dear ImGui v1.91.5
FetchContent_Declare(imgui
GIT_REPOSITORY https://github.com/ocornut/imgui.git
GIT_TAG v1.91.5)
FetchContent_MakeAvailable(imgui)
add_library(imgui STATIC
${imgui_SOURCE_DIR}/imgui.cpp
${imgui_SOURCE_DIR}/imgui_draw.cpp
${imgui_SOURCE_DIR}/imgui_tables.cpp
${imgui_SOURCE_DIR}/imgui_widgets.cpp
${imgui_SOURCE_DIR}/imgui_demo.cpp
${imgui_SOURCE_DIR}/backends/imgui_impl_glfw.cpp
${imgui_SOURCE_DIR}/backends/imgui_impl_opengl3.cpp
)
target_include_directories(imgui PUBLIC
${imgui_SOURCE_DIR}
${imgui_SOURCE_DIR}/backends
)
target_link_libraries(imgui PUBLIC glfw)
add_executable(sudoku
src/main.cpp
src/grid.cpp
src/ui_panel.cpp
src/app.cpp
)
target_link_libraries(sudoku PRIVATE imgui)
if(APPLE)
target_link_libraries(sudoku PRIVATE
"-framework OpenGL"
"-framework Cocoa"
"-framework IOKit"
"-framework CoreVideo"
)
elseif(WIN32)
target_link_libraries(sudoku PRIVATE opengl32)
elseif(UNIX)
find_package(OpenGL REQUIRED)
target_link_libraries(sudoku PRIVATE OpenGL::GL)
endif()
target_compile_options(sudoku PRIVATE -Wall -Wextra -Wpedantic)
Explanation:
FetchContent: automatically clones GLFW + ImGui from GitHub and builds them alongside the app. The first run is slow (~2 minutes).imguiSTATIC lib: ImGui ships without a CMakeLists, so we declare our ownadd_librarywith the 5 core files + 2 backend files.- Platform linking: macOS uses Apple frameworks, Windows uses
opengl32.lib, Linux usesOpenGL::GL(viafind_package).
5. Key learning — Lesson 1 vs Lesson 0
| Lesson 0 (setup) | Lesson 1 (Phase 1) |
|---|---|
| Toolchain works | Real C++ code runs |
| cmake --build has nothing to build | Builds a real executable |
| No class is written yet | A class with constructor/destructor + non-copyable |
| ImGui = abstract | Concrete code using ImGui Begin/End, BeginTable |
New patterns introduced:
- RAII — tie resource lifetime to object lifetime
- Forward declaration — reduce header dependency
= delete— explicit non-copyable- In-class initializer — modern alternative to constructor init lists
- Immediate-mode GUI — UI =
f(state), redrawn every frame extern constvs defining in the header — ODR-safe
6. How to verify it works
Build
cd apps/sudoku
cmake -S . -B build # configure (the first run takes ~2 minutes)
cmake --build build # compile
Expected output:
[100%] Linking CXX executable sudoku
[100%] Built target sudoku
Run
# Mac
./build/sudoku
# Windows
build\Debug\sudoku.exe
Visual checklist
- [ ] A 720x800 window opens, titled "Sudoku"
- [ ] Inside the window there is a 9x9 grid with borders
- [ ] Cells with a preset number (fixed, from
SAMPLE_PUZZLE): the number is shown in white and is not editable - [ ] Empty cells: have an input cell, accept digits 1-9, letters are filtered out
- [ ] 3x3 boxes are distinguished by background color: cell (0,0) light, cell (0,3) dark, cell (3,3) light, etc. — checkerboard pattern
- [ ] Closing the window → the app exits cleanly (terminal does not hang, no crash log)
7. Common errors / pitfalls
| Symptom | Cause | Fix |
|---|---|---|
| fatal error: 'GLFW/glfw3.h' file not found | Configure has not finished, FetchContent is still downloading | Wait for cmake -S . -B build to finish |
| Linker error _glfwInit referenced but not defined (Mac) | Missing Cocoa/IOKit framework | Check target_link_libraries(... -framework Cocoa ...) |
| Cells display the number twice | Forgot to check inside the fixed if/else | Fix the logic: render in only one of the two branches |
| ImGui assertion g.IDStack.size() != 1 | Forgot PopID after PushID | Match every PushID with one PopID |
| All cells focus into a single one | Duplicate ID "##cell" without PushID | Wrap PushID(r*N+c) ... PopID() around the InputText |
| Typing letters into a cell still accepts them | Forgot ImGuiInputTextFlags_CharsDecimal | Add the flag |
| IM_COL32 does not exist | Forgot to include imgui.h | Add the include |
| EndTable() is called when BeginTable returned false | Wrong logic | Wrap EndTable inside if (BeginTable(...)) { ... EndTable(); } |
| The window never closes when clicking X | The game loop checks the wrong variable | Loop check !glfwWindowShouldClose(window_) |
| At app exit, a GLFW assertion crashes | Double glfwTerminate() | Idempotent shutdown with an if (window_) guard |
8. Room for self-study
- Color scheme: replace
IM_COL32(40, 40, 40, 255)with a different color. Try blue (IM_COL32(20, 30, 80, 255)). - Window resize: the app is currently fixed at 720x800. Find the GLFW flag that allows resizing. Verify whether the grid auto-adapts or breaks.
StyleColorsLightvsStyleColorsDark: switch them ininit(). Compare the UX.ImGui::ShowDemoWindow(): add it todraw_frame(). Explore the widget demo.- Cell padding: find a way to increase the padding inside a cell (hint:
ImGui::PushStyleVar(ImGuiStyleVar_CellPadding, ...)). - Tab key navigation: try Tab between the editable cells. Does it work? Why?
- Save puzzle: write
grid_to a text file every time the app exits. Load it back on the next run.
9. End-of-lesson exercises
Exercise 1 — Reset button
Add a "Reset" button before the Table. Click → reload SAMPLE_PUZZLE into grid_.
Exercise 2 — Cell size slider
Add a slider before the Table: Cell size (px) from 20 to 80. Default 40. The cell width should animate according to the slider value.
Exercise 3 — Number input via keyboard 1-9
Currently only empty cells take input through text. Add this: when a cell is focused and the user presses keys 1-9, set the value immediately (no need to click). Hint: ImGui::IsItemFocused() + ImGui::IsKeyPressed(ImGuiKey_1).
Exercise 4 — Color by box index
Instead of a 2-color checkerboard, use 9 different colors for the 9 boxes (rainbow). Hint: use box_r * 3 + box_c ∈ [0, 9) as the hue.
Exercise 5 (challenge) — Multiple puzzle samples
Define an array SAMPLES[][] holding 5 different puzzles. Add a combobox "Sample" for the user to choose. Click → reload.
10. Exercise answer key
Exercise 1 — Reset button
Inside App::draw_frame():
void App::draw_frame() {
ImGui::Begin("Sudoku");
if (ImGui::Button("Reset")) {
grid_ = load_puzzle(SAMPLE_PUZZLE);
}
panel_.draw(grid_, fixed_);
ImGui::End();
}
Note: fixed_ does not need to be reloaded — it is invariant (because SAMPLE_PUZZLE does not change).
Exercise 2 — Cell size slider
App adds a field:
float cell_size_ = 40.0f;
UIPanel::draw adds a parameter:
void draw(Grid&, const FixedMap&, float cell_size);
In draw_frame:
ImGui::SliderFloat("Cell size", &cell_size_, 20.0f, 80.0f);
panel_.draw(grid_, fixed_, cell_size_);
In draw:
ImGui::TableSetupColumn("", ImGuiTableColumnFlags_WidthFixed, cell_size);
// ...
ImGui::SetNextItemWidth(cell_size - 6.0f); // input narrower than cell
Exercise 3 — Keyboard 1-9 input
In UIPanel::draw, after InputText:
ImGui::PushID(r * N + c);
ImGui::SetNextItemWidth(34.0f);
ImGui::InputText("##cell", buf, sizeof(buf), ImGuiInputTextFlags_CharsDecimal);
// Keyboard shortcut when the cell is focused
if (ImGui::IsItemFocused()) {
for (int k = 1; k <= 9; ++k) {
ImGuiKey key = (ImGuiKey)(ImGuiKey_1 + k - 1);
if (ImGui::IsKeyPressed(key)) {
grid[r][c] = k;
}
}
if (ImGui::IsKeyPressed(ImGuiKey_Backspace) ||
ImGui::IsKeyPressed(ImGuiKey_Delete) ||
ImGui::IsKeyPressed(ImGuiKey_0)) {
grid[r][c] = 0;
}
}
ImGui::PopID();
Exercise 4 — 9 color boxes
static const ImU32 BOX_COLORS[9] = {
IM_COL32(60, 30, 30, 255),
IM_COL32(60, 50, 30, 255),
IM_COL32(50, 60, 30, 255),
IM_COL32(30, 60, 30, 255),
IM_COL32(30, 60, 50, 255),
IM_COL32(30, 50, 60, 255),
IM_COL32(30, 30, 60, 255),
IM_COL32(50, 30, 60, 255),
IM_COL32(60, 30, 50, 255),
};
int box_idx = (r / 3) * 3 + (c / 3);
ImGui::TableSetBgColor(ImGuiTableBgTarget_CellBg, BOX_COLORS[box_idx]);
Exercise 5 (challenge) — Multiple samples
grid.cpp:
const char* SAMPLE_PUZZLES[] = {
"53..7....6..195....98....6.8...6...34..8.3..17...2...6.6....28....419..5....8..79",
"..3.2.6..9..3.5..1..18.64....81.29..7.......8..67.82....26.95..8..2.3..9..5.1.3..",
"..36..58.5..89..7....2...........3.18..9.8..67.2...........5....7..63..8.81..47..",
// ... 2 more ...
};
constexpr int SAMPLE_COUNT = sizeof(SAMPLE_PUZZLES) / sizeof(SAMPLE_PUZZLES[0]);
grid.h:
extern const char* SAMPLE_PUZZLES[];
constexpr int SAMPLE_COUNT = 3; // hardcode or use inline constexpr (C++17)
App field + UI:
int selected_sample_ = 0;
// in draw_frame:
const char* names[] = { "Sample 1", "Sample 2", "Sample 3" };
if (ImGui::Combo("Puzzle", &selected_sample_, names, SAMPLE_COUNT)) {
grid_ = load_puzzle(SAMPLE_PUZZLES[selected_sample_]);
fixed_ = make_fixed_map(grid_);
}
Combo returns true only when the user picks a different item → triggers the reload.
→ Next: 02-phase2-validator.md — Phase 2: Strategy pattern with the Validator