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

Creating Win32 Windows and Translating OS Messages into Engine Events

Hello! Your engine can now report failures through its diagnostics layer. This lesson gives it a visible Windows application boundary: a Win32 window that receives operating-system messages and turns the few messages the engine cares about into engine-owned events. You’ll use the sandbox to verify closing, resizing, and focus changes. Allow about 50–60 minutes, including the resources and implementation.

The distinction matters beyond this first window. A renderer will need the current client-area size, while gameplay and tools will need to know when the window loses focus. Neither should have to interpret WM_SIZE or include <windows.h>.


From Windows messages to engine events

Win32 associates a window procedure with a registered window class. Windows calls that procedure with a message identifier and message-specific parameters. Some messages are retrieved from a thread’s queue; others can be delivered directly. Your procedure handles the messages it recognizes and passes the rest to DefWindowProcW so normal window behavior remains intact.

Watch these portions of Aerideus’s “Creating a Window using C++ and Win32” for the window-creation sequence and the non-blocking message pump. The video’s application structure differs from ours; use it to understand the Win32 calls rather than copy its class verbatim.

Creating a Window using C++ and Win32 | Tutorial

Aerideus explains how a window is created and why a game-oriented application retrieves messages without waiting for each one.

Watch message handling for the distinction between GetMessage and PeekMessage. Then watch client size and window creation for AdjustWindowRect and CreateWindowEx. Finish with window procedure and message pump; notice that WM_QUIT is checked by the pump rather than handled as an ordinary window event.

There is one subtlety worth establishing before writing code: calling DispatchMessageW is not the only way the window procedure runs. Windows can send messages during CreateWindowExW itself. We must therefore make our window object available to the procedure as the window is being created, not only after creation returns.

Read the short routing and default-processing passages in Microsoft Learn’s “About Messages and Message Queues.”

About Messages and Message Queues - Win32 apps

Microsoft Learn distinguishes queued messages from directly sent messages and explains why an application must preserve default handling.

In “Message Routing,” read the routing distinction. Then find “Window Procedure” and read default processing. Focus on why our event queue supplements Win32 handling rather than replacing DefWindowProc.

For this first boundary, the translation policy will be deliberately small:

Win32 message Engine result
WM_CLOSE Queue CloseRequested; the application decides to destroy the window.
WM_SIZE Queue Resized with the current client-area dimensions.
WM_SETFOCUS, WM_KILLFOCUS Queue FocusChanged.
WM_DESTROY Post WM_QUIT for this single-window sandbox.
WM_PAINT Validate the paint request; drawing is not implemented yet.
Other messages Use DefWindowProcW.

A CloseRequested event is a request, not proof that the window has already closed. That distinction will let an editor decide what to do with unsaved work later.


Define the Windows-free interface

Add engine/include/engine/Window.hpp:

#pragma once

#include <memory>

namespace engine {

struct WindowEvent {
    enum class Type {
        CloseRequested,
        Resized,
        FocusChanged
    };

    Type type;
    int width = 0;
    int height = 0;
    bool focused = false;
};

class Window {
public:
    Window(int client_width, int client_height, const wchar_t* title);
    ~Window();

    Window(const Window&) = delete;
    Window& operator=(const Window&) = delete;
    Window(Window&&) = delete;
    Window& operator=(Window&&) = delete;

    // Dispatch pending Win32 messages; false means the thread received WM_QUIT.
    bool pump_messages();

    // Retrieve one translated event, if available.
    bool poll_event(WindowEvent& event);

    // Destroy this window. WM_DESTROY will request termination.
    void close();

private:
    struct Impl;
    std::unique_ptr<Impl> impl_;
};

} // namespace engine

The Impl pointer keeps Win32 types out of the public header. This is the same ownership principle you practiced earlier: one Window owns one native window, and copying it would make destruction ambiguous. Keep creation, pumping, event retrieval, and destruction on the window-creating thread.

The event queue also separates when Windows delivers a message from when the application handles its meaning. In particular, a resize can arrive during creation; it remains queued until the sandbox polls it.


Register and create the native window

Create engine/src/Window.cpp. Start with the includes, native state, and a failure helper:

#include <engine/Window.hpp>
#include <engine/Diagnostics.hpp>

#include <deque>
#include <stdexcept>
#include <string>

#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <windows.h>

namespace engine {
namespace {

constexpr wchar_t kClassName[] = L"EngineMainWindow";

[[noreturn]] void fail_win32(const char* operation) {
    const DWORD error = GetLastError(); // Capture before another Win32 call.
    log(LogChannel::Platform, Severity::Error,
        std::string(operation) + " failed; GetLastError=" +
        std::to_string(error));
    throw std::runtime_error(operation);
}

} // namespace

struct Window::Impl {
    HINSTANCE instance = nullptr;
    HWND hwnd = nullptr;
    std::deque<WindowEvent> events;
    bool event_failure = false;

    static LRESULT CALLBACK procedure(
        HWND hwnd, UINT message, WPARAM wparam, LPARAM lparam);
};

Now add the constructor and destructor after Impl in the same file:

Window::Window(int client_width, int client_height, const wchar_t* title)
    : impl_(std::make_unique<Impl>()) {
    if (client_width <= 0 || client_height <= 0 || title == nullptr) {
        throw std::invalid_argument("Invalid window description");
    }

    impl_->instance = GetModuleHandleW(nullptr);
    if (!impl_->instance) {
        fail_win32("GetModuleHandleW");
    }

    const DWORD style = WS_OVERLAPPEDWINDOW;
    RECT bounds{0, 0, client_width, client_height};
    if (!AdjustWindowRect(&bounds, style, FALSE)) {
        fail_win32("AdjustWindowRect");
    }

    WNDCLASSW window_class{};
    window_class.lpfnWndProc = &Impl::procedure;
    window_class.hInstance = impl_->instance;
    window_class.lpszClassName = kClassName;
    window_class.hCursor = LoadCursorW(nullptr, IDC_ARROW);

    if (!RegisterClassW(&window_class)) {
        fail_win32("RegisterClassW");
    }

    impl_->hwnd = CreateWindowExW(
        0, kClassName, title, style,
        CW_USEDEFAULT, CW_USEDEFAULT,
        bounds.right - bounds.left,
        bounds.bottom - bounds.top,
        nullptr, nullptr, impl_->instance, impl_.get());

    if (!impl_->hwnd) {
        const DWORD error = GetLastError();
        UnregisterClassW(kClassName, impl_->instance);
        log(LogChannel::Platform, Severity::Error,
            "CreateWindowExW failed; GetLastError=" +
            std::to_string(error));
        throw std::runtime_error("CreateWindowExW");
    }

    ShowWindow(impl_->hwnd, SW_SHOW);
    log(LogChannel::Platform, Severity::Info, "Window created");
}

Window::~Window() {
    if (impl_->hwnd) {
        DestroyWindow(impl_->hwnd);
    }
    UnregisterClassW(kClassName, impl_->instance);
}

AdjustWindowRect accounts for the title bar and borders: the requested dimensions describe the drawing area, not the outside of the frame. The last argument to CreateWindowExW passes impl_.get() into the creation messages. The procedure will retrieve it shortly.

This class is intentionally for one live main window. Two simultaneous instances would try to register the same class name. Supporting several editor windows calls for shared class-registration ownership, but would obscure today’s message boundary.


Translate messages without swallowing Windows behavior

Add the procedure definition below the constructor and destructor:

LRESULT CALLBACK Window::Impl::procedure(
    HWND hwnd, UINT message, WPARAM wparam, LPARAM lparam) {
    if (message == WM_NCCREATE) {
        auto* creation = reinterpret_cast<CREATESTRUCTW*>(lparam);
        auto* self = static_cast<Impl*>(creation->lpCreateParams);

        SetWindowLongPtrW(
            hwnd, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(self));
    }

    auto* self = reinterpret_cast<Impl*>(
        GetWindowLongPtrW(hwnd, GWLP_USERDATA));

    if (message == WM_NCDESTROY) {
        if (self) {
            self->hwnd = nullptr;
        }
        SetWindowLongPtrW(hwnd, GWLP_USERDATA, 0);
        return DefWindowProcW(hwnd, message, wparam, lparam);
    }

    if (self) {
        try {
            switch (message) {
            case WM_CLOSE:
                self->events.push_back(
                    {WindowEvent::Type::CloseRequested});
                return 0; // Do not destroy it before the app handles the event.

            case WM_SIZE: {
                RECT client{};
                GetClientRect(hwnd, &client);
                self->events.push_back({
                    WindowEvent::Type::Resized,
                    client.right - client.left,
                    client.bottom - client.top
                });
                return 0;
            }

            case WM_SETFOCUS:
                self->events.push_back({
                    WindowEvent::Type::FocusChanged, 0, 0, true
                });
                return 0;

            case WM_KILLFOCUS:
                self->events.push_back({
                    WindowEvent::Type::FocusChanged, 0, 0, false
                });
                return 0;

            case WM_DESTROY:
                PostQuitMessage(0);
                return 0;

            case WM_PAINT: {
                PAINTSTRUCT paint{};
                BeginPaint(hwnd, &paint);
                EndPaint(hwnd, &paint);
                return 0;
            }

            default:
                break;
            }
        } catch (...) {
            // In particular, a deque allocation must not throw through
            // a callback invoked by Win32.
            self->event_failure = true;
            PostQuitMessage(1);
            return 0;
        }
    }

    return DefWindowProcW(hwnd, message, wparam, lparam);
}

WM_NCCREATE attaches the Impl pointer before ordinary creation messages need it. WM_NCDESTROY removes that association when the native window is going away. The other handled cases return a message result; everything else retains Windows’ default behavior.

Notice two less obvious choices. First, GetClientRect obtains the size we will eventually give the renderer, rather than the frame’s outer size. A minimized window may report zero dimensions; keep that event so future rendering code can avoid drawing or resizing buffers to zero. Second, WM_PAINT merely validates the paint region for now. It is not the engine’s future rendering loop.

Finish the file with the public methods:

bool Window::pump_messages() {
    MSG message{};

    while (PeekMessageW(&message, nullptr, 0, 0, PM_REMOVE)) {
        if (message.message == WM_QUIT) {
            if (impl_->event_failure) {
                log(LogChannel::Platform, Severity::Error,
                    "Could not queue a window event");
            }
            return false;
        }

        TranslateMessage(&message);
        DispatchMessageW(&message);
    }

    return true;
}

bool Window::poll_event(WindowEvent& event) {
    if (impl_->events.empty()) {
        return false;
    }

    event = impl_->events.front();
    impl_->events.pop_front();
    return true;
}

void Window::close() {
    if (impl_->hwnd) {
        DestroyWindow(impl_->hwnd);
    }
}

} // namespace engine

PeekMessageW returns when the queue is empty, leaving time for engine work. TranslateMessage supports character-message generation when text input is added later; DispatchMessageW delivers a retrieved window message to its procedure. WM_QUIT is different: it is a thread-queue message observed by the pump, not a WindowEvent delivered to the procedure.


Wire up the sandbox and inspect the boundary

Add Window.cpp and Window.hpp to the existing Engine target’s source and public-header lists, respectively. In engine/CMakeLists.txt, link the Win32 window functions:

target_link_libraries(Engine PRIVATE user32)

Adapt the sandbox’s existing main rather than creating another executable. Include the new header and use its event loop in the sandbox startup path:

#include <engine/Window.hpp>
#include <engine/Diagnostics.hpp>

#include <chrono>
#include <exception>
#include <string>
#include <thread>

// Inside the sandbox's existing main:
try {
    engine::Window window(1280, 720, L"Engine Sandbox");

    while (true) {
        const bool os_running = window.pump_messages();

        engine::WindowEvent event{};
        while (window.poll_event(event)) {
            switch (event.type) {
            case engine::WindowEvent::Type::CloseRequested:
                engine::log(engine::LogChannel::Platform,
                            engine::Severity::Info, "Close requested");
                window.close();
                break;

            case engine::WindowEvent::Type::Resized:
                engine::log(engine::LogChannel::Platform,
                            engine::Severity::Info,
                            "Client area: " + std::to_string(event.width) +
                            " x " + std::to_string(event.height));
                break;

            case engine::WindowEvent::Type::FocusChanged:
                engine::log(engine::LogChannel::Platform,
                            engine::Severity::Info,
                            event.focused ? "Focus gained" : "Focus lost");
                break;
            }
        }

        if (!os_running) {
            break;
        }

        // Temporary idle behavior, not the engine's frame scheduler.
        std::this_thread::sleep_for(std::chrono::milliseconds(16));
    }
} catch (const std::exception& error) {
    engine::log(engine::LogChannel::Platform,
                engine::Severity::Error, error.what());
    return 1;
}

return 0;

The order is deliberate: drain translated events even if the pump found WM_QUIT. The temporary sleep prevents this window-only demonstration from spinning at full CPU usage; it does not promise a frame rate or a simulation timestep.

Build the sandbox in Debug. Resize the window and check that logged dimensions describe its client area. Switch focus to another application and back. Minimize and restore it, then close it with the title-bar button and confirm that the process exits. Repeat a quick build in Release to catch configuration differences. Your existing automated tests should still build without needing to create a GUI window; this first end-to-end check is best performed in the sandbox.

If closing hides the window but leaves the process running, trace the complete close path: WM_CLOSE queues the request, the sandbox calls close(), destruction produces WM_DESTROY, and the pump must observe WM_QUIT. The Platform log messages make it possible to locate the missing link.


Takeaways

You now own a Win32 window through a non-copyable engine object and expose close, resize, and focus as engine events instead of Win32 constants. The window procedure preserves default OS handling, while the non-blocking pump leaves room for ongoing engine work.

Next, you’ll replace this temporary idle loop with a measured game loop that separates fixed-step simulation from variable-rate rendering. The event boundary can remain in place while the work performed between message pumps grows.

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

Sign up