learning.
learning14 min read

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.

sudokuimguiglfwraiic++self-study

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 Grid type — std::array<std::array<int, 9>, 9> holding the puzzle
  • A FixedMap — a 9x9 bool marking the fixed cells (from the original puzzle)
  • An App class using the RAII pattern (GLFW init/shutdown)
  • A UIPanel class 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/cpp to libs/solver/. This lesson keeps the code in apps/sudoku/src/.

3. Content to learn

  1. 2D std::array — fixed-size compile-time container
  2. using alias — C++11 type alias (vs typedef)
  3. constexpr — type-safe compile-time constant
  4. extern const — declaration/definition separation to avoid ODR violation
  5. = default, = delete — explicit default + non-copyable
  6. In-class member initializer — initialize a field at the declaration site
  7. Forward declaration — reduce include weight
  8. Game loop pattern — immediate-mode rendering
  9. ImGui Table API — BeginTable, TableSetupColumn, TableNextRow/Column
  10. PushID/PopID stack — 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 including app.h does not need to know the struct internals, it only uses GLFWwindow* (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. = delete prevents 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 write Grid 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 validators parameter. Phase 3.0.1 will add a draw_controls method. 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 call EndTable() 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_c even/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(...) with static at 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_DEPRECATION before #include <GLFW/glfw3.h>: silences Apple's deprecation warning about OpenGL (macOS has moved to Metal).
  • Idempotent shutdown(): the if (window_) guard allows it to be called multiple times without crashing. The destructor also calls shutdown() → safe if the user happened to call it already before destruction.
  • Game loop inside run(): 4 steps per frame:
    1. Poll input (glfwPollEvents)
    2. ImGui new frame (3 calls — they must follow the backend order exactly)
    3. Build the UI tree (draw_frame)
    4. Render → OpenGL → swap buffers

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 → when main returns, 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).
  • imgui STATIC lib: ImGui ships without a CMakeLists, so we declare our own add_library with the 5 core files + 2 backend files.
  • Platform linking: macOS uses Apple frameworks, Windows uses opengl32.lib, Linux uses OpenGL::GL (via find_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 const vs 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

  1. Color scheme: replace IM_COL32(40, 40, 40, 255) with a different color. Try blue (IM_COL32(20, 30, 80, 255)).
  2. Window resize: the app is currently fixed at 720x800. Find the GLFW flag that allows resizing. Verify whether the grid auto-adapts or breaks.
  3. StyleColorsLight vs StyleColorsDark: switch them in init(). Compare the UX.
  4. ImGui::ShowDemoWindow(): add it to draw_frame(). Explore the widget demo.
  5. Cell padding: find a way to increase the padding inside a cell (hint: ImGui::PushStyleVar(ImGuiStyleVar_CellPadding, ...)).
  6. Tab key navigation: try Tab between the editable cells. Does it work? Why?
  7. 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