Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

Categorized Logging, Assertions, and Debug Breaks for Development Builds

Hello! In the previous lesson, you made resource ownership explicit and tested when objects are released. The next step is to make failures visible: when a resource cannot be created, you need a useful report; when an ownership assumption is violated, you want to stop near the mistake rather than discover it much later.

In this lesson, you’ll add a small diagnostics layer to the existing Engine library. It will label messages by subsystem and severity, check programmer assumptions in Debug builds, and pause in an attached Windows debugger when an assertion fails. Allow about 40–50 minutes, including the resources and implementation.


Give every diagnostic two answers

A useful engine log entry answers two different questions. Where did this happen? is its category: Core, Platform, Render, or World. How serious is it? is its severity: Info, Warning, Error, and so on. Keeping these separate matters once terrain generation, rendering, and asset loading are all producing messages at once.

For example, a missing optional texture might be a Render warning if a fallback texture can be used. Failure to create the renderer might be a Render error followed by a controlled startup failure. Neither should be an assertion merely because it is inconvenient: an assertion is for a condition that your program logic says cannot legitimately occur.

Watch the selected portions of Progrematic’s “Adding Spdlog and a Log Manager” for the design of a logger with an output destination and for the distinction between reporting an assertion and actually stopping at it. The video uses a third-party logger and a different build setup; we’ll implement a smaller interface in your CMake project.

06 - Adding Spdlog and a Log Manager | C++ Game Engine Programming

Progrematic’s “Adding Spdlog and a Log Manager” shows how engine code can send messages through one logging interface and how an assertion can preserve a useful call stack.

In logger setup, focus on the distinction between the logger and its output sink, rather than copying its build steps. Then watch assertion purpose for an example of a programmer error, and debugger break for why printing a message alone is not enough to inspect the failing call.

For now, our sink will write to standard error and, on Windows, send the same line to debugger output. A file sink, richer formatting, or an established library such as the one in the video can come later without requiring every subsystem to change its call sites.

DebugView displaying captured debug-print lines with times and process IDs. Sending engine log lines to Windows debugger output provides a second place to inspect messages when a console is inconvenient.

Put a small logging interface in Engine

Create engine/include/engine/Diagnostics.hpp. The public interface names categories and severities, but does not expose how messages are written.

#pragma once

#include <string_view>

namespace engine {

enum class LogChannel {
    Core,
    Platform,
    Render,
    World
};

enum class Severity {
    Trace,
    Debug,
    Info,
    Warning,
    Error,
    Critical
};

void set_minimum_severity(Severity minimum);
void log(LogChannel channel, Severity severity,
         std::string_view message);

[[noreturn]] void assertion_failed(
    const char* expression, const char* file, int line);

} // namespace engine

#if ENGINE_DEV_CHECKS
    #define ENGINE_LOG_DEBUG(channel, message) \
        ::engine::log((channel), ::engine::Severity::Debug, (message))
    #define ENGINE_LOG_TRACE(channel, message) \
        ::engine::log((channel), ::engine::Severity::Trace, (message))
    #define ENGINE_ASSERT(expression) \
        do { \
            if (!(expression)) { \
                ::engine::assertion_failed( \
                    #expression, __FILE__, __LINE__); \
            } \
        } while (false)
#else
    #define ENGINE_LOG_DEBUG(channel, message) do { (void)0; } while (false)
    #define ENGINE_LOG_TRACE(channel, message) do { (void)0; } while (false)
    #define ENGINE_ASSERT(expression) do { (void)0; } while (false)
#endif

Ordinary log(...) calls are available in every build. The two low-severity macros and ENGINE_ASSERT are intended for development instrumentation: their arguments are not evaluated when checks are disabled. The do { ... } while (false) form makes each macro behave as a single statement, including inside an if statement. The #expression, __FILE__, and __LINE__ pieces preserve the failed condition and its source location.

Now create engine/src/Diagnostics.cpp:

#include <engine/Diagnostics.hpp>

#include <atomic>
#include <cstdio>
#include <cstdlib>
#include <mutex>
#include <string>

#if defined(_WIN32)
    #ifndef NOMINMAX
        #define NOMINMAX
    #endif
    #include <windows.h>
#endif

namespace engine {
namespace {

std::mutex output_mutex;
std::atomic<Severity> minimum_severity{Severity::Info};

const char* channel_name(LogChannel channel) {
    switch (channel) {
    case LogChannel::Core:     return "Core";
    case LogChannel::Platform: return "Platform";
    case LogChannel::Render:   return "Render";
    case LogChannel::World:    return "World";
    }
    return "Unknown";
}

const char* severity_name(Severity severity) {
    switch (severity) {
    case Severity::Trace:    return "Trace";
    case Severity::Debug:    return "Debug";
    case Severity::Info:     return "Info";
    case Severity::Warning:  return "Warning";
    case Severity::Error:    return "Error";
    case Severity::Critical: return "Critical";
    }
    return "Unknown";
}

} // namespace

void set_minimum_severity(Severity minimum) {
    minimum_severity.store(minimum, std::memory_order_relaxed);
}

void log(LogChannel channel, Severity severity,
         std::string_view message) {
#if !ENGINE_DEV_CHECKS
    // Direct calls cannot re-enable development-only levels in Release.
    if (severity == Severity::Trace || severity == Severity::Debug) {
        return;
    }
#endif

    if (severity < minimum_severity.load(std::memory_order_relaxed)) {
        return;
    }

    std::string line;
    line.reserve(message.size() + 64);
    line += '[';
    line += channel_name(channel);
    line += "] [";
    line += severity_name(severity);
    line += "] ";
    if (!message.empty()) {
        line.append(message.data(), message.size());
    }
    line += '\n';

    std::lock_guard<std::mutex> lock(output_mutex);
    std::fwrite(line.data(), 1, line.size(), stderr);
    std::fflush(stderr);

#if defined(_WIN32)
    OutputDebugStringA(line.c_str());
#endif
}

[[noreturn]] void assertion_failed(
    const char* expression, const char* file, int line) {
    std::string description =
        std::string("Assertion failed: ") + expression +
        " at " + file + ":" + std::to_string(line);

    log(LogChannel::Core, Severity::Critical, description);

#if defined(_WIN32)
    if (IsDebuggerPresent()) {
        DebugBreak();
    }
#endif

    std::abort();
}

} // namespace engine

The mutex keeps two threads from interleaving their output lines. The atomic minimum severity makes the simple filter safe to change while another thread logs; it does not make unrelated engine objects thread-safe. Critical still passes the filter because it is the highest severity.

OutputDebugStringA is an additional Windows destination, not a replacement for standard error. Depending on how the process is run, you may see those lines in your debugger’s output window or capture them with a utility such as DebugView. For an assertion, IsDebuggerPresent() prevents an intentional debug-break exception when no debugger is attached; the process then aborts either way. If you continue execution after the debugger break, it aborts as well—we do not want to carry on after a failed invariant.

This is deliberately a starter logger, not a crash-proof logging system. Constructing a message can allocate memory, for example, so it cannot be relied on to diagnose every out-of-memory failure. Keep that limitation in mind when interpreting missing messages.


Make Debug and Release agree on the interface

Add src/Diagnostics.cpp to the existing Engine target’s sources and include/engine/Diagnostics.hpp to its existing public header file set in engine/CMakeLists.txt. Then add this definition, using your engine target’s actual name if it differs:

target_compile_definitions(Engine PUBLIC
    "ENGINE_DEV_CHECKS=$<IF:$<CONFIG:Debug>,1,0>"
)

PUBLIC is important here. The assertion macro is in a header used by the sandbox and tests, so those targets must receive the same configuration value as the compiled engine library. This setup enables checks in CMake’s Debug configuration and disables them in the other configurations; it does not rely on a compiler-specific _DEBUG definition.

A sandbox call can now carry both dimensions of a diagnostic:

engine::log(engine::LogChannel::World,
            engine::Severity::Info,
            "Beginning world generation");

ENGINE_LOG_DEBUG(engine::LogChannel::World,
                 "Inspecting initial chunk");

The default minimum is Info, so set it to Debug or Trace during a Debug run when you want to see those lower-severity lines:

engine::set_minimum_severity(engine::Severity::Debug);

This gives you two controls with different jobs. The build configuration removes development-only log calls from Release; the runtime minimum controls how much of the remaining output you currently want to see. Avoid logging once per terrain vertex or once per frame at Info: even useful messages become difficult to search when produced at that rate.


Use assertions for assumptions, not recovery

Before placing assertions throughout the engine, read Microsoft Learn’s “Use C++ assertion statements for debugging.” Its warnings apply to our macro even though we are implementing our own reporting and break behavior.

Use C++ assertion statements for debugging - Visual Studio (Windows) | Microsoft Learn

Read Microsoft Learn’s guidance to distinguish a development check from error handling that must work in a shipped build.

In “Side effects of using assertions,” read from the paragraph beginning “When you add assertions to your code” through its warning about function calls: side effects. Then, under “Using assertions” and “Finding unhandled errors,” read from “Assertion statements are not a substitute” to the end of the example’s explanation: error handling.

Consider a texture-cache lookup. If internal code promises that a particular cache entry exists, an assertion can expose a broken invariant:

ENGINE_ASSERT(found != textures_.end());

But if an asset path supplied by a scene may be missing, that is a runtime failure. Log an error and return a failure result or use a fallback; do not rely on an assertion that disappears outside Debug:

if (found == textures_.end()) {
    engine::log(engine::LogChannel::Render,
                engine::Severity::Error,
                "Required texture was not found");
    return nullptr;
}

Also keep side effects outside assertion expressions. ENGINE_ASSERT(++count == expected) changes count in Debug but not Release. Evaluate the operation first, then assert a condition about its result. The explicit failure checks in your existing ownership tests must remain: they are tests of behavior in both configurations, not merely development-time assumptions.


Check the integration without crashing CTest

Add the following near the end of the existing tests/src/main.cpp, before its final return 0;. Keep the earlier ownership checks intact.

engine::set_minimum_severity(engine::Severity::Trace);

engine::log(engine::LogChannel::Core,
            engine::Severity::Info,
            "Diagnostics smoke test");
ENGINE_LOG_DEBUG(engine::LogChannel::World,
                 "Development log is enabled");

ENGINE_ASSERT(shared_counts.destroyed == 1);

#if !ENGINE_DEV_CHECKS
// In Release this compiles to a no-op; the test must still complete.
ENGINE_ASSERT(false);
#endif

Also include <engine/Diagnostics.hpp> at the top. The deliberately false assertion is compiled only into the Release test, where the macro does nothing. Do not put a deliberately failing Debug assertion into the normal CTest run: it would abort the test or pause an attached debugger.

Build and run the existing tests in both configurations:

cmake --build build --config Debug
ctest --test-dir build -C Debug --output-on-failure

cmake --build build --config Release
ctest --test-dir build -C Release --output-on-failure

If your generator uses separate build directories rather than a multi-configuration build, configure a Debug and a Release directory and run each with its matching build configuration. Run EngineTests directly when you want to inspect the log lines; a successful CTest run may not display captured output.

For a separate manual debugger check, temporarily put ENGINE_ASSERT(false); in a sandbox startup path, run that sandbox under the debugger in Debug, and inspect the message and call stack when it stops. Remove the line afterward. This confirms the behavior without turning an intentional crash into a permanent automated test.


Takeaways

You now have one engine-owned place to report diagnostics, with category identifying the subsystem and severity identifying urgency. Debug and trace calls, along with assertions, are compiled out of non-Debug builds; ordinary errors and their recovery paths remain. A failed development assertion reports the condition and location, pauses an attached Windows debugger, and ultimately aborts instead of silently continuing.

Next, you’ll create a Win32 window and translate operating-system messages into engine events. The diagnostics layer will be useful immediately for reporting window-creation failures and observing platform events while you build that boundary.

Can't find a good explanation? Sign up and we'll make it for you

Sign up