DEV Community

Cover image for Certamen: Learn How I made a TUI in C++ with Local/SSH modes
N. Adhikary
N. Adhikary

Posted on

Certamen: Learn How I made a TUI in C++ with Local/SSH modes

Intro

Motivation

As a university student with examinations around the corner, one grows tired of the same Flashcards and Quizlet decks.For this reason, I wanted something that I could use freely as a Terminal User Interface that paired well with the rest of my dotfiles and respective setup. At a later point, this turned to some rivalry based on points scored on Who knows the most Haskell? (I do!)

After finding and contributing to FTXUI1 on Github, I decided to develop Certamen (latin: contest)2.

Scope of This Document

This post traces the full chronological development of Certamen across 105 commits in roughly 5 weeks of development which sprouts off of the initial CLI prototype made back in September 2025 to the current state of the project as of 3rd of April, 2026. This is designed to cover and explain my personal architectural choices, the TUI migration, the SSH server implementation, packaging for multiple platforms, and the refactoring passes that followed.

---://-

I: The CLI Prototype, "Quizzer"

The Initial Version

The project began back in 29th September 2025 under the name Quizzer as a single-file CLI application. With a main.cpp worth ca. 300 lines; it used yaml-cpp3 for quiz serialisation from a top-down mapping sequence with the standard std::cin/std::cout user interaction.

The original data structure worked like so:

struct Question
{
    std::string question;
    std::vector<std::string> choices;
    int answer; // 'answer' is an index onto 'choices'
    std::optional<std::string> code;
    std::optional<std::string> explain;
};
Enter fullscreen mode Exit fullscreen mode

Quiz files at this stage were flat YAML sequences with question, choices and answer as mandatory fields:

- question: What is 2+2?
  choices: [3, 4, 5, 6]
  answer: 1
Enter fullscreen mode Exit fullscreen mode

The main menu was rendered trivially:

static int menu_choice(bool randomise)
{
    std::cout << "\nQuizzer (Randomise: " << (randomise ? "ON" : "OFF") << ")\n"
                 "1. Take Quiz\n"
                 "2. Add Question\n"
                 "3. Remove Question\n"
                 "4. Change Answer\n"
                 "5. List Questions\n"
                 "6. Save and Exit\n"
                 "7. Toggle Randomise\n";
    return read_int_in_range(1, 7, "Choose (1-7): ");
}
Enter fullscreen mode Exit fullscreen mode

All input was blocking, validated through helper functions such as read_int_in_range, read_line, and read_yes_no to reduce duplications of code. The build system was a straightforward CMakeLists.txt linking yaml-cpp:

cmake_minimum_required(VERSION 3.12)
project(quizzer LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
find_package(yaml-cpp REQUIRED)
add_executable(quizzer main.cpp)
target_link_libraries(quizzer PRIVATE yaml-cpp)
Enter fullscreen mode Exit fullscreen mode

While functional, this is not the end product I wanted for this passion project; I decided it would be more of use to others and myself if there were more highlights, options, and a more entertaining interface in comparison to the traditional black and white CLI. Thus, one decides to write a Terminal UI for this, not Graphics!

Pre-Fixes

The idea of making it a TUI was 5 months after the Quizzer app was built in September. Due to this, a few critical bugs were addressed and a few features were implemented by 2026-03-06 to ease the transition:

  • Ctrl+D hang: The program would enter an infinite loop on EOF because std::cin stream state was not being checked.
  • Score display: The quiz result formatting was corrected, alongside which many optimisations were made.
  • Diff before quit: A feature was added to show unsaved changes before exiting, which now developed into a full system to depict differences between original and modified states!
  • Explanations and code in list view: List Questions was debugged to properly print code blocks and explanations inline, while earlier it had issues with sequencing and showing them.

---://-

II: The TUI Migration

Decomposition

On 2026-03-06, the centralised main.cpp was split into a header/source structure under src/. This was the first step towards at least some modularity:

src/
  main.cpp          # screen routing
  app.hpp           # AppState struct, screen enum
  model.hpp/.cpp    # higher level data structures
  syntax.hpp/.cpp
  screens/
    menu.cpp/.hpp
    quiz.cpp/.hpp
    quiz_result.cpp/.hpp
    add_question.cpp/.hpp
    ...
Enter fullscreen mode Exit fullscreen mode

Every screen reads/writes to a single mutable state object; that being AppState. The reasoning behind the use of a flat state struct in place of a class hierarchy (OOP) was mainly due to the fact that FTXUI components are closures that capture references, and a single owning struct simplifies lifetime management.

enum class AppScreen
{
    MENU,
    QUIZ,
    QUIZ_RESULT,
    ADD_QUESTION,
    // ... rest of the main menu options
};

struct AppState
{
    std::vector<Question> questions;
    bool randomise = false;
    std::string status_message;
    AppScreen current_screen = AppScreen::MENU;
    // ... rest fields for screen-specific state
};
Enter fullscreen mode Exit fullscreen mode

Screen "routing" is implemented through a Container::Tab indexed by the enumeration:

auto tab = Container::Tab(std::move(screens), &screen_index);

auto main_renderer = Renderer(tab, [&] {
    screen_index = static_cast<int>(state.current_screen);
    return tab->Render() | size(WIDTH, LESS_THAN, 120) | center;
});
Enter fullscreen mode Exit fullscreen mode

This is a state machine! My favourite! where AppScreen is the state and user input events are the transitions. Each screen is a self-contained FTXUI component produced by a factory function e.g. make_menu_screen(AppState&), and screen transitions are performed by mutating state.current_screen.

FTXUI: The Terminal UI Framework

FTXUI1 is the rendering engine used for this project. It is useful in many ways:

  1. Declarative rendering: the Render() lambda returns an element tree each frame; meaning that FTXUI handles diff-ing against the terminal (emulator).
  2. Component model: CatchEvent wrappers allow composing input handling without subclassing or further abstraction.
  3. Platform compatibility: Linux, and Homebrew (macOS/Windows).

I did not want to move this to more popular alternatives such as BubbleTea or LipGloss, etc. since (1) I did not want to switch to a different language and (2) I wanted my code to be (less) indexed by LLMs.

The CMake integration changed from a single-target build to a multi-library link, FTXUI facilitates Make processes by allowing FetchContent:

include(FetchContent)
FetchContent_Declare(ftxui
  GIT_REPOSITORY https://github.com/ArthurSonzogni/FTXUI
  GIT_TAG v6.1.9
)
FetchContent_MakeAvailable(ftxui)

find_package(yaml-cpp REQUIRED)
find_package(libssh REQUIRED) # ssh to come

file(GLOB_RECURSE SOURCES src/*.cpp)
add_executable(certamen ${SOURCES})

target_link_libraries(certamen
  PRIVATE ftxui::screen ftxui::dom ftxui::component
  PRIVATE yaml-cpp
  PRIVATE ssh                 # ssh to come
  PRIVATE util
)
Enter fullscreen mode Exit fullscreen mode

FTXUI provides three libraries: ftxui::screen, ftxui::dom for the element tree (layout, borders, colours), and ftxui::component for widgets and event handling.

Screen Implementation

Each screen follows the same pattern. Here is the quiz screen as an example, which demonstrates the rendering of questions with code blocks, choice selection, and answer feedback after which a Renderer is returned to spit it out:

ftxui::Component make_quiz_screen(AppState& state)
{
    auto focusable = Renderer([](bool) { return text(""); });

    auto component = CatchEvent(focusable, [&](Event event) {
        // for example if I want to navigate using arrowkeys
        if (nav_up_down(event, state.quiz_selected, num_choices)) return true;
        // or if I want to use numbers, 0 -> 9.
        if (nav_numeric(event, state.quiz_selected, num_choices)) return true;

        if (event == Event::Return)
        {
            state.quiz_answered = true;
            state.quiz_was_correct = (state.quiz_selected == q.answer);
            if (state.quiz_was_correct) ++state.quiz_score;
            return true;
        }
        // ...
    });

    return Renderer(component, [&] {
        // build element *tree* i.e. header, progress gauge, question, choices, explaination
        Elements body;
        body.push_back(gauge(progress) | color(Color::Cyan));
        body.push_back(paragraph(" " + q.question) | bold);

        if (q.code && !q.code->empty())
            body.push_back(render_code_block(*q.code, q.language));

        for (int i = 0; i < num_choices; ++i)
        {
            // colour based on selection state and correctness (so if I chose wrong, _RED_; else _GREEN_)
            auto choice_el = hbox({
                text(marker), text(std::to_string(i + 1) + ". "), text(q.choices[i]),
            });
            if (state.quiz_answered && is_correct)
                choice_el = choice_el | color(Color::Green) | bold;
            // ...
        }
        return vbox(std::move(body)) | borderRounded;
    });
}
Enter fullscreen mode Exit fullscreen mode

The Renderer wraps a CatchEvent that processes input and mutates AppState. The rendering lambda reads state each frame to produce the element tree (as shown in Line 21 above). FTXUI diffentiates this against the previous frame and emits only the necessary terminal escape sequences.
Now, in what I said above, I used a lot of jargon; what really happens is:
FTXUI sees "Hey! this has changed!" and it goes "Let's update it and show the user what the program resolved to!"
That is all.
Regardless, this separation of input handling from rendering is what permits the TUI to remain responsive, due to the continous "diffing"4.

Syntax Highlighting Engine

A bespoke highlighter was implemented in syntax.cpp to render code blocks within quiz questions themselves, this way the user has an easier time parsing the code with their eyes. It supports four language families (Haskell, C-family, Python, Rust) with keyword colouring, string literals, numeric literals, comments (line/block), and operators.

The design is a hand-written lexer that tokenises each line:

enum class Lang { Haskell, CFam, Python, Rust, Unknown };

static const std::unordered_set<std::string> haskell_kw = { // for example, for haskell:
    "module","where","let","in","if","then","else","case","of","do",
    // etc...
};

static Element highlight_line(const std::string& line, Lang lang,
                              const std::unordered_set<std::string>& kw)
{
    Elements parts;
    // detect comments, strings, numbers, keywords, operators
    // emit coloured text spans for any given token
    while (i < len)
    {
        if (/* line comment */)  flush_text(line.substr(i), Color::GrayDark);
        if (/* string literal */) flush_text(s, Color::Yellow);
        if (/* number */)         flush_text(num, Color::Magenta);
        if (kw.count(word))       flush_text(word, Color::Cyan);
        // ...
    }
    return hbox(std::move(parts));
}
Enter fullscreen mode Exit fullscreen mode

As you must be already familiar by now, Color::name signifies a base set colour which most Terminal Emulators support the rendering of! =)

The code block is then rendered with a language label and border:

ftxui::Element render_code_block(
    const std::string& code,
    const std::optional<std::string>& language)
{
    Lang lang = detect_lang(language);
    const auto& kw = keywords_for(lang);

    Elements lines;
    std::istringstream stream(code);
    std::string line;
    while (std::getline(stream, line))
        lines.push_back(hbox({ text("  "), highlight_line(line, lang, kw) }));

    std::string label = " Code";
    if (language && !language->empty())
        label += " [" + *language + "]";

    return vbox({
        text(label) | bold | color(Color::Cyan),
        vbox(std::move(lines)),
    }) | borderRounded | color(Color::GrayLight);
}
Enter fullscreen mode Exit fullscreen mode

This is NOT a production-grade syntax highlighter. It does not handle multi-line strings, heredocs, or nested block comments spanning lines. It handles the common cases sufficient for quiz code snippets, and I did not want to add a linter dependency!

---://-

III: Name Change

Quizzer to Certamen

On 2026-03-26, the project was faithfully renamed from Quizzer to Certamen. The word is Latin for contest or quiz contest, found via Wikipedia2.

This was executed across two commits (09ce38b, d4eb31c). One wonders, what is one of the easiest ways to replace this string accross all these files? With sed:

find . -type f -exec sed -i 's/quizzer/certamen/g' {} +
Enter fullscreen mode Exit fullscreen mode

To explain this, find initially finds all files from the ./ directory i.e. current working directory.
After which, -exec sed -i 's/quizzer/certamen/g' {} + executes sed, which edits each file in place with the aforementioned regex. {} signifies the current file being processed kept in memory, and + groups multiple files together in find.

---://-

IV: The New Quiz Wrapper

YAML Schema Evolution

The original Quizzer used a flat YAML sequence as shown earlier, however, when the TUI was built, the schema was wrapped with some metadata. That being name and author; to accomodate for this, previous question were nested inside questions:

name: Certamen DEMO
author: trintlermint
questions:
  - question: Which Haskell functions can calculate the Euclidean norm?
    code: |
      nrm1 :: Double -> Double -> Double
      nrm1 a b = sqrt (a^2 + b^2)
    explain: |
      Both definitions compute sqrt(a^2 + b^2).
    language: haskell
    choices:
      - Only nrm1 is correct.
      - Only nrm2 is correct.
      - Both are correct.
      - Neither is correct.
    answer: 2
Enter fullscreen mode Exit fullscreen mode

The language field was added alongside the syntax highlighter. The Question struct gained it as well:

struct Question
{
    // ... rest
    std::optional<std::string> language;
};
Enter fullscreen mode Exit fullscreen mode

The QuizFile struct holds the new top-level metadata:

struct QuizFile
{
    std::string name;
    std::string author;
    std::vector<Question> questions;
};
Enter fullscreen mode Exit fullscreen mode

Serialisation uses yaml-cpp's emitter API with YAML::Literal for multi-line code and explanation fields shown with how | is used earlier along with this new emitter:

void save_quiz(const QuizFile& quiz, const std::string& filename)
{
    YAML::Emitter out;
    out << YAML::BeginMap;
    out << YAML::Key << "name"   << YAML::Value << quiz.name;
    out << YAML::Key << "author" << YAML::Value << quiz.author;
    out << YAML::Key << "questions" << YAML::Value;
    // emit_questions handles the sequence
    out << YAML::EndMap;

    std::ofstream file_out(filename);
    file_out << out.c_str();
}
Enter fullscreen mode Exit fullscreen mode

---://-

V: Server Shell

Architecture

The Server Shell (SSH) server mode under option certamen serve was the most architecturally ambitious feature. The design forks a child process per client, connected via a PTY (pseudo-terminal), so that the full TUI renders identically over SSH as it does locally.

The current flow behind-the-scenes:

  1. serve_main binds an SSH socket using libssh, this is the listener for connections to the server.
  2. connection => fork() creates a handler for the client.
  3. handle_client performs a SSH key exchange, authenticates the user, channel negotiation, and shell requests.
  4. forkpty creates a PTY pair and forks again; the child then runs certamen --session (itself) via execvp without asking client.
  5. bridge_io shuttles data between the SSH channel and the PTY master using poll().
// The core I/O bridge goes as SSH channel and PTY master bidirectionally
static void bridge_io(ssh_channel channel, int master_fd, ssh_session session)
{
    char buf[8192];
    int ssh_fd = ssh_get_fd(session);

    int flags = fcntl(master_fd, F_GETFL, 0);
    if (flags >= 0) fcntl(master_fd, F_SETFL, flags | O_NONBLOCK);

    while (ssh_channel_is_open(channel) && !ssh_channel_is_eof(channel))
    {
        struct pollfd fds[2];
        fds[0].fd = ssh_fd;    fds[0].events = POLLIN;
        fds[1].fd = master_fd; fds[1].events = POLLIN;

        int ret = poll(fds, 2, 200);
        if (ret < 0) { if (errno == EINTR) continue; break; }

        // SSH to PTY
        if (fds[0].revents & (POLLIN | POLLHUP | POLLERR))
        {
            ssh_execute_message_callbacks(session);
            while (true)
            {
                int n = ssh_channel_read_nonblocking(channel, buf, sizeof(buf), 0);
                if (n > 0) write(master_fd, buf, n);
                else break;
            }
        }

        // PTY to SSH
        if (fds[1].revents & POLLIN)
        {
            while (true)
            {
                ssize_t n = read(master_fd, buf, sizeof(buf));
                if (n > 0) ssh_channel_write(channel, buf, n);
                else break;
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The Session Mode

When the server forks a child, it executes certamen --session --metrics /tmp/certamen_metrics_XXXXXX <quiz files>. The --session flag triggers session_main that presents a stripped-down experience, which asks:

  1. Name: The client enters their display name.
  2. Picker: If multiple files are loaded, a menu to select one only.
  3. Quiz: You're in! The standard quiz screen (mid-game).
  4. Result: Score display, then return to the picker OR disconnect.
int session_main(const std::vector<std::string>& quiz_files,
                 const std::string& metrics_file)
{
    auto screen = ScreenInteractive::Fullscreen();

    std::string player_name;
    run_name_prompt(player_name, screen);
    if (player_name.empty()) return 0;

    if (quiz_files.size() == 1)
    {
        auto [score, total] = run_quiz(quiz_files[0], screen);
        write_metrics(metrics_file, player_name, quiz_files[0], score, total);
        return 0;
    }

    while (true)
    {
        int chosen = run_quiz_picker(quiz_files, player_name, screen);
        if (chosen < 0) break;
        auto [score, total] = run_quiz(quiz_files[chosen], screen);
        write_metrics(metrics_file, player_name, quiz_files[chosen], score, total);
    }
    return 0;
}
Enter fullscreen mode Exit fullscreen mode

The metrics are written to a temporary file in tmp/ as illustrated earlier. Then read by the parent server process after the child exits, logged to stdout of the actual server, and then cleaned up:

[2026-03-26 20:18:45] METRICS [vanilla]: player=vanilla
[2026-03-26 20:18:45] METRICS [vanilla]: quiz=algebra.yaml
[2026-03-26 20:18:45] METRICS [vanilla]: score=8/10
Enter fullscreen mode Exit fullscreen mode

Terminal Resize Handling

Window resize events from the SSH client are sent through a message callback that updates the PTY dimensions:

static int message_callback(ssh_session, ssh_message msg, void* userdata)
{
    int master_fd = *static_cast<int*>(userdata);

    if (ssh_message_type(msg) == SSH_REQUEST_CHANNEL &&
        ssh_message_subtype(msg) == SSH_CHANNEL_REQUEST_WINDOW_CHANGE)
    {
        int cols = ssh_message_channel_request_pty_width(msg);
        int rows = ssh_message_channel_request_pty_height(msg);
        struct winsize ws{};
        ws.ws_col = static_cast<unsigned short>(cols);
        ws.ws_row = static_cast<unsigned short>(rows);
        ioctl(master_fd, TIOCSWINSZ, &ws);
        ssh_message_channel_request_reply_success(msg);
        return 0;
    }
    return 1;
}
Enter fullscreen mode Exit fullscreen mode

Platform Concerns

The SSH server uses forkpty()5 which is POSIX-specific. The header inclusion is branched:

#if defined(__linux__)
  #include <pty.h>
#elif defined(__APPLE__)
  #include <util.h>
#else
  // windows: no pty.h/openpty path in this implementation yet
#endif
Enter fullscreen mode Exit fullscreen mode

This is the primary reason Windows builds are marked as continue-on-error: true in the CI pipeline. The local TUI mode works on Windows; the server does not however.

Authentication

The server supports two authentication modes:

  • Open (default): Any SSH client connects without a password. The SSH username becomes the player name.
  • Password: Pass --password <pw> and clients must authenticate, shown below:
# Open
certamen serve quiz.yaml

# Password
certamen serve --password quiznight --port 3000 quiz.yaml

# Client
ssh -p 3000 alice@192.168.1.10
Enter fullscreen mode Exit fullscreen mode

The host RSA key is auto-generated on first run using ssh_pki_generate and stored as certamen_host_rsa with permissions 0600, that is, sudo (root) can read/write, other users cannot however.

---://-

VI: Multi-File Support

The Problem

Initially, Certamen loaded a single YAML file. For a quiz night with multiple topics (say, algebra, history, and most importantly: Haskell), one would need to merge them manually or run separate instances. On 2026-03-28, multi-file support was implemented.

Data Architecture

Each loaded file is tracked by a LoadedFile struct:

struct LoadedFile
{
    std::string filename;
    std::string name;
    std::string author;
    std::vector<Question> saved_questions;
    std::string saved_name;
    std::string saved_author;
};
Enter fullscreen mode Exit fullscreen mode

Questions carry a source_file index (-1 for unassigned). The AppState then holds a flat std::vector<Question> with all questions from all files chosen, along with std::vector<LoadedFile> for per-file metadata. This allows source_file to serve as an index to enable per-file saving and editing for each screen to use.

Screen Routing with File Picker

When multiple files are loaded, editing operations (add, remove, etc.) must target a specific file. The route_to method intercepts navigation:

AppScreen route_to(AppScreen dest)
{
    if (loaded_files.size() > 1)
    {
        pick_file_cursor = 0;
        pick_file_then = dest;
        return AppScreen::PICK_FILE;
    }
    target_file = loaded_files.empty() ? -1 : 0;
    build_target_indices();
    return dest;
}
Enter fullscreen mode Exit fullscreen mode

If only one file is loaded it implies that routing proceeds directly. Else, the user is first sent to a file picker screen, then forwarded to the intended destination. The pick_file_then field stores the deferred target.

Quiz Setup Screen

When taking a quiz with multiple files, the user selects which files to include and in what order in local mode. The QUIZ_SETUP screen has two parts:

  • State 0: Toggle inclusion of each file (checkbox-style).
  • State 1: Reorder the included files.

The selected questions are then concatenated and fed to start_quiz_from().

One trivially sees that since Randomise already juggles around ALL loaded questions and choices, if Randomise is enabled, there is no file picker. Perhaps this is unintuitive, and the order of files should still be present? Do let me know!

Unsaved Change Tracking

The diff system compares the current in-memory state against the last-saved snapshot per file:

bool has_unsaved_changes() const
{
    for (int i = 0; i < static_cast<int>(loaded_files.size()); ++i)
    {
        const auto& lf = loaded_files[i];
        if (i == 0 && (quiz_name != lf.saved_name || quiz_author != lf.saved_author))
            return true;

        std::vector<Question> current;
        for (const auto& q : questions)
            if (q.source_file == i)
                current.push_back(q);

        if (current != lf.saved_questions)
            return true;
    }
    for (const auto& q : questions)
        if (q.source_file < 0) return true;
    return false;
}
Enter fullscreen mode Exit fullscreen mode

We do not use the original file due to the fact that the original file may already have been edited mid-session or outside session. As the program has no way to know if this is the case, it saves snapshots instead.

Diff lines are generated with prefix markers [+] for additions, [-] for deletions, [~] for changes in answer/choices, and [0] for no changes, then rendered with colour coding via the shared diff.hpp utility:

inline ftxui::Elements render_diff_lines(const std::vector<std::string>& diff_lines)
{
    Elements entries;
    for (const auto& line : diff_lines)
    {
        Color line_color = Color::Default;
        if (line.size() > 1 && line[0] == '[')
        {
            if (line[1] == '+') line_color = Color::Green;
            else if (line[1] == '-') line_color = Color::RedLight;
            else if (line[1] == '~') line_color = Color::Yellow;
        }
        entries.push_back(text("  " + line) | color(line_color));
    }
    return entries;
}
Enter fullscreen mode Exit fullscreen mode

The menu screen displays a modified indicator in yellow when unsaved changes are present.


VII: CI/CD and Packaging

GitHub Actions

On 2026-03-27, a multi-platform CI/CD pipeline was established using GitHub Actions. The workflow triggers on tag pushes (git tag v* && git push origin v*) and builds on three platforms:

jobs:
  build-linux:
    runs-on: ubuntu-latest
    steps:
      - name: Install dependencies
        run: sudo apt-get install cmake ninja-build libyaml-cpp-dev libssh-dev
      - name: Configure
        run: cmake --preset release
      - name: Build
        run: cmake --build --preset release --config Release
      - name: Package
        run: |
          mkdir certamen-linux-x64
          cp build/bin/certamen certamen-linux-x64/
          tar -czf certamen-linux-x64.tar.gz certamen-linux-x64/

  build-macos:
    runs-on: macos-latest
    # similar, using brew for dependencies

  build-windows:
    runs-on: windows-latest
    continue-on-error: true # SSH server incompatible so far
    # uses vcpkg for yaml-cpp and libssh
Enter fullscreen mode Exit fullscreen mode

The release job depends only on Linux and macOS; Windows is optional because the forkpty-based SSH server cannot compile there as mentioned previously. Releases are created automatically via softprops/action-gh-release.

Few _hot_fixes followed the initial CI setup:

  • 26bd229: Platform-specific headers in serve.cpp were branched (#if defined(__linux__) / #elif defined(__APPLE__)) to fix macOS builds as was shown earlier in Platform Concers
  • cd197e6: CMake presets were added for consistent cross-platform configuration.

Nix Packaging

A Nix flake was contributed by @valyntyler on 2026-03-28, providing reproducible builds:

# flake.nix
{
  inputs = {
    nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable";
    flake-parts.url = "github:hercules-ci/flake-parts";
  };
  outputs = inputs @ {flake-parts, ...}:
    flake-parts.lib.mkFlake {inherit inputs;} {
      systems = ["x86_64-linux" "aarch64-linux" "aarch64-darwin" "x86_64-darwin"];
      perSystem = {pkgs, ...}: rec {
        devShells.default = pkgs.callPackage ./nix/shell.nix {};
        packages.certamen = pkgs.callPackage ./nix/package.nix {};
        packages.default = packages.certamen;
      };
    };
}
Enter fullscreen mode Exit fullscreen mode

The package derivation:

# nix/package.nix
{ cmake, stdenv, ftxui, libssh, yaml-cpp }:
let inherit (stdenv) mkDerivation;
in mkDerivation {
  pname = "certamen";
  version = "1.0.3";
  src = ../.;
  buildInputs = [ cmake ftxui libssh yaml-cpp ]; # deps
  configurePhase = ''cmake -B build -DCMAKE_BUILD_TYPE=Release'';
  buildPhase = ''cmake --build build'';
  installPhase = ''
    mkdir -p $out/bin
    cp ./build/bin/certamen $out/bin/certamen
    chmod +x $out/bin/certamen
  ''; # execution
  meta.mainProgram = "certamen";
}
Enter fullscreen mode Exit fullscreen mode

This enables imperative installation trivially via:

nix profile add github:trintlermint/certamen#certamen
Enter fullscreen mode Exit fullscreen mode

Several CMake-related fixes followed to ensure the Nix build found ftxui as a system package rather than fetching it via FetchContent. The CMakeLists.txt was updated to attempt find_package(ftxui QUIET) first:

find_package(ftxui QUIET)
if (NOT ftxui_FOUND)
  include(FetchContent)
  FetchContent_Declare(ftxui
    GIT_REPOSITORY https://github.com/ArthurSonzogni/FTXUI
    GIT_TAG v6.1.9
  )
  FetchContent_MakeAvailable(ftxui)
endif()
Enter fullscreen mode Exit fullscreen mode

This is done due to the fact that many consumers of NixPkgs do not allow web Fetch access to programs such as CMake due to vulnerability concerns.

AUR Packaging

Certamen was published to the Arch Linux User Repository, installable via:

sudo pacman -S certamen
Enter fullscreen mode Exit fullscreen mode

Or through any other AUR helper/wrapper.

This was natural considering I use Arch Linux, btw!


VIII: Manual and QOL Features

In-Game Manual

On 2026-03-31, a built-in manual screen was implemented. Accessible via option 10 on the menu or by pressing 0, it provides a reference for all features, keybindings, and quiz format documentation without leaving the TUI. Which is seemingly rather helpful for new users! Less README!

Codeberg Migration

The repository has also been mirrored to Codeberg due to concerns about licensing, code ownership, and platform ethics. The README is now embossed with dual badges:
CodebergGitHub

Stance on AI

CONTRIBUTING.md was updated with the project's stance on AI-assisted contributions, as bearing the brainmade.org mark, this project accepts NO purely "vibe-coded" contributions.
As shown by this "vibe-coded" pull request rejection.

Brainmade Org Certamen


IX: Code Quality and Refactoring

Shared Utility Headers

After the feature set stabilised, duplicated code patterns were extracted into shared headers:

Navigation (nav.hpp)

Six screen files contained near-identical j/k/arrow-binds/1-9 navigation logic. This was pushed into two inline functions:

inline bool nav_up_down(const ftxui::Event& event, int& selected, int count)
{
    if (event == ftxui::Event::ArrowUp || event == ftxui::Event::Character('k'))
    {
        if (selected > 0) selected--;
        return true;
    }
    if (event == ftxui::Event::ArrowDown || event == ftxui::Event::Character('j'))
    {
        if (selected < count - 1) selected++;
        return true;
    }
    return false;
}

inline bool nav_numeric(const ftxui::Event& event, int& selected, int count)
{
    if (!event.is_character()) return false;
    char ch = event.character()[0];
    int num = ch - '1';
    if (num >= 0 && num < count)
    {
        selected = num;
        return true;
    }
    return false;
}
Enter fullscreen mode Exit fullscreen mode

This replaced approximately 100 lines of duplicated code across quiz.cpp, change_answer.cpp, remove_question.cpp, edit_choice.cpp, and list_questions.cpp.

Diff Rendering (diff.hpp)

The diff colouring logic was duplicated between save_confirm.cpp and quit_confirm.cpp. The shared render_diff_lines function shown earlier eliminated this.

AppState Helper

The pattern state.current_screen = AppScreen::MENU; state.status_message.clear(); appeared in 15 locations. It was also lovingly compressed:

void return_to_menu()
{
    current_screen = AppScreen::MENU;
    status_message.clear();
}
Enter fullscreen mode Exit fullscreen mode

Along with this, several other dead code and typos were fixed which appeared throughout the development process


Reflections

The Flat State Decision

The decision to use a single AppState struct with 40+ fields rather than per-screen state objects or a more structured hierarchy was thoughtful. In FTXUI, components are typically closures capturing references. A single struct means that any given screen can read any state it needs without ownership loop-de-loops! The tradeoff is that AppState is large and its fields are loosely grouped by comments rather than enforced by rigor in types.

For a project of this scale (ca. 4200 lines across 30 source files), this is acceptable. A larger project would benefit from per-screen state structs composed within AppState, or message-passing architecture. The current design was chosen because it permitted rapid iteration during the intensive short term development sprint due to my current examinations.

The PTY Fork Design

The SSH server's design of forking the entire binary with --session and bridging via PTY is rather unconventional, however effective. The alternative would be to run the TUI rendering in-process and translate FTXUI's output to SSH channel writes. This would require either:

  1. A custom Screen implementation that writes to an SSH channel instead of stdout, or
  2. Intercepting FTXUI's terminal output at the file descriptor level.

Both approaches are brittle and couple the server tightly to FTXUI's internals, making it difficult for someone to contribute openly, or for a system administrator to understand. The forkpty + execvp approach treats the TUI as a "black box":

Does it work locally? then it must it over SSH. (hmmm . . . .)

The cost is a process per client, which is negligible for the expected concurrency (I dont imagine having a gigantic quiz night with 200 people, yet).

Specifically, there is absolutely no way I am convincing 200 people to play quizzes with me over the terminal.


Conclusion

Certamen began as a 300-line CLI tool for quizzing myself on Haskell functions on my initial quartile 1 exams, and within a few weeks after months, it grew into a pile of 4200-line TUI application with syntax highlighting, multi-file quiz sessions, SSH multiplayer, cross-platform CI/CD, and packaging for AUR and Nix.

The codebase is full of spiky edges. There are NO automated tests which makes Quality Assessment a nightmare; the state struct is more centralised than I enjoy myself; the Windows build is perpetually broken. These are understandable costs for a project whose primary purpose is serving as a personal Free and Open Source utility.

Certamen means contest. The real contest, as it seems to turn out, was finishing this project before the next exam season!

For information see my blog post on Certamen!


  1. FTXUI: Functional Terminal (X) User Interface. See ArthurSonzogni/FTXUI on GitHub, version 6.1.9 used. 

  2. See the Wikipedia article on Certamen, Latin for contest or quiz competition. 

  3. yaml-cpp: A YAML parser and emitter for C++. See jbeder/yaml-cpp on GitHub. 

  4. FTXUI uses a declarative rendering model: the component returns an Element tree each frame, and the framework computes the minimal set of terminal escape sequences to update the display. This is conceptually similar to React's virtual DOM diffing. 

  5. The forkpty approach was inspired by how ttyd and similar web-terminal tools expose TUI applications over HTTP/WebSocket by bridging PTY I/O. 

Top comments (0)