Create a Win32 Window

Step-by-step: register a window class, create the window with CreateWindowEx, write a message loop, and end up with a clean blank surface ready for DirectX 12.

Early Draft Work in progress — GitHub links coming soon.

In the previous post we looked at the shape of a Win32 app: setup code, a window handle, a message queue, and a WndProc that reacts to messages. Now we will build the smallest useful version of that structure.

By the end of this article you will have a native Windows program that:

We are still not rendering anything with DX12 yet. That is deliberate. Before the GPU enters the story, we want the windowing layer to feel boring and predictable.

The Shape of the Program

A Win32 window program has five pieces:

  1. An entry point where Windows starts your program.
  2. A window procedure that receives messages for the window.
  3. A window class that connects the procedure to a named kind of window.
  4. A window instance created from that class.
  5. A message loop that keeps the app alive.

The order matters:

wWinMain starts
    -> RegisterClassExW
    -> CreateWindowExW
    -> ShowWindow
    -> message/render loop
    -> WndProc handles messages
    -> WM_DESTROY posts WM_QUIT

The important mental model is this: CreateWindowExW does not just make pixels appear. It creates a window object owned by Windows, gives your app an HWND handle for it, and connects that object to your WndProc.

Includes and Compile Settings

Start with:

#define WIN32_LEAN_AND_MEAN
#include <windows.h>

WIN32_LEAN_AND_MEAN leaves out a lot of older Windows API surface we do not need here. It keeps compile times down and reduces the number of names pulled into your translation unit.

This article uses the wide-character Win32 APIs: wWinMain, RegisterClassExW, CreateWindowExW, and wide string literals such as L"DX12 Sandbox". Modern Windows is Unicode internally, so using the W APIs keeps the code explicit and avoids depending on project-wide UNICODE macros.

Declare the Window Procedure

Windows needs a function it can call whenever a message is dispatched to your window:

LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam);

That signature is fixed by Win32:

You do not call WndProc yourself. Your message loop calls DispatchMessage, and Windows calls the appropriate window procedure for the message’s target window.

Step 1: Register a Window Class

A window class is a template for windows of a particular kind. It does not create a visible window by itself. It tells Windows, “When I create a window with this class name, use this cursor, these style flags, and this window procedure.”

constexpr wchar_t MainWindowClassName[] = L"GameDevInstitute.MainWindow";

bool RegisterMainWindowClass(HINSTANCE hInstance)
{
    WNDCLASSEXW wc = {};
    wc.cbSize        = sizeof(WNDCLASSEXW);
    wc.style         = CS_HREDRAW | CS_VREDRAW;
    wc.lpfnWndProc   = WndProc;
    wc.hInstance     = hInstance;
    wc.hCursor       = LoadCursorW(nullptr, IDC_ARROW);
    wc.hbrBackground = nullptr;
    wc.lpszClassName = MainWindowClassName;

    return RegisterClassExW(&wc) != 0;
}

A few fields matter most:

The class name should be unique enough that it will not collide with another class in your process. A short namespaced string is a good habit.

Step 2: Ask for a Client Area

For graphics programming, the window’s client area matters more than the total outer window size. The client area is the drawable rectangle inside the title bar and borders. Your DX12 swap chain back buffers will match this area.

If you pass 1280, 720 directly to CreateWindowExW, you are asking for an outer window that includes borders and the title bar, so the drawable area will be smaller than 1280 x 720.

Use AdjustWindowRectEx to convert the desired client size into the required outer size:

RECT ComputeWindowRect(int clientWidth, int clientHeight, DWORD style, DWORD exStyle)
{
    RECT rect = { 0, 0, clientWidth, clientHeight };
    AdjustWindowRectEx(&rect, style, FALSE, exStyle);
    return rect;
}

The third parameter is FALSE because this window does not have a menu. Menus take up vertical space, so Windows needs to know whether one exists when it computes the outer rectangle.

Step 3: Create the Window

Now create an actual window instance:

HWND CreateMainWindow(HINSTANCE hInstance, int clientWidth, int clientHeight)
{
    const DWORD style = WS_OVERLAPPEDWINDOW;
    const DWORD exStyle = 0;

    RECT rect = ComputeWindowRect(clientWidth, clientHeight, style, exStyle);

    return CreateWindowExW(
        exStyle,
        MainWindowClassName,
        L"DX12 Sandbox",
        style,
        CW_USEDEFAULT,
        CW_USEDEFAULT,
        rect.right - rect.left,
        rect.bottom - rect.top,
        nullptr,
        nullptr,
        hInstance,
        nullptr);
}

WS_OVERLAPPEDWINDOW is the standard resizable desktop window style. It includes a title bar, border, system menu, minimize button, maximize button, and sizing frame.

The last parameter, lpParam, is nullptr for now. In larger programs, this is often where you pass a pointer to your application object. You can retrieve it during WM_NCCREATE or WM_CREATE and store it with SetWindowLongPtr. We will keep this version procedural so the message flow is easy to see.

Step 4: Handle the Important Messages

The first version of WndProc only needs a few cases:

LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam)
{
    switch (msg)
    {
        case WM_SIZE:
        {
            const int width = LOWORD(lParam);
            const int height = HIWORD(lParam);

            if (width > 0 && height > 0)
            {
                // Later: resize the DX12 swap chain buffers here.
            }

            return 0;
        }

        case WM_CLOSE:
            DestroyWindow(hwnd);
            return 0;

        case WM_DESTROY:
            PostQuitMessage(0);
            return 0;
    }

    return DefWindowProcW(hwnd, msg, wParam, lParam);
}

WM_SIZE passes the new client width and height through lParam. This will become important as soon as the swap chain exists, because resizing the window means resizing the back buffers.

There are two close-related messages worth separating:

Calling DestroyWindow from WM_CLOSE accepts the close request. Calling PostQuitMessage(0) from WM_DESTROY tells the message loop to stop.

Everything else goes to DefWindowProcW. That is not a fallback for mistakes; it is part of normal Win32 programming. Windows already knows how to handle the default behavior for hundreds of messages.

Step 5: Write a Render-Friendly Message Loop

A normal desktop app can use GetMessage, which blocks until a message arrives:

while (GetMessageW(&msg, nullptr, 0, 0) > 0)
{
    TranslateMessage(&msg);
    DispatchMessageW(&msg);
}

Games and real-time renderers usually need a different rhythm. They should process all pending messages, then render when the queue is empty:

int RunRenderLoop()
{
    MSG msg = {};

    while (msg.message != WM_QUIT)
    {
        if (PeekMessageW(&msg, nullptr, 0, 0, PM_REMOVE))
        {
            TranslateMessage(&msg);
            DispatchMessageW(&msg);
        }
        else
        {
            // Later: RenderFrame();
        }
    }

    return static_cast<int>(msg.wParam);
}

PeekMessageW returns immediately. If it found a message, we translate and dispatch it. If the queue was empty, we have time to render a frame.

For this article the render branch is empty. In the next few posts, that branch becomes the heartbeat of the app: update game state, record commands, execute the command list, and present the swap chain.

Step 6: Put It Together in wWinMain

wWinMain is the Unicode entry point for a Windows GUI subsystem app:

int WINAPI wWinMain(
    HINSTANCE hInstance,
    HINSTANCE,
    PWSTR,
    int nCmdShow)
{
    if (!RegisterMainWindowClass(hInstance))
    {
        MessageBoxW(nullptr, L"RegisterClassExW failed.", L"Startup Error", MB_ICONERROR);
        return -1;
    }

    HWND hwnd = CreateMainWindow(hInstance, 1280, 720);
    if (!hwnd)
    {
        MessageBoxW(nullptr, L"CreateWindowExW failed.", L"Startup Error", MB_ICONERROR);
        return -1;
    }

    ShowWindow(hwnd, nCmdShow);
    UpdateWindow(hwnd);

    return RunRenderLoop();
}

ShowWindow makes the window visible. UpdateWindow asks Windows to send an initial paint message if one is needed. With DX12 we will render on our own clock, but calling UpdateWindow is still a normal part of showing a new Win32 window.

The MessageBoxW calls are intentionally plain. Early in a native app, before you have logging or an on-screen UI, a startup failure needs somewhere obvious to go.

Complete Source

Here is the full program in one file:

#define WIN32_LEAN_AND_MEAN
#include <windows.h>

constexpr wchar_t MainWindowClassName[] = L"GameDevInstitute.MainWindow";

LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam);

RECT ComputeWindowRect(int clientWidth, int clientHeight, DWORD style, DWORD exStyle)
{
    RECT rect = { 0, 0, clientWidth, clientHeight };
    AdjustWindowRectEx(&rect, style, FALSE, exStyle);
    return rect;
}

bool RegisterMainWindowClass(HINSTANCE hInstance)
{
    WNDCLASSEXW wc = {};
    wc.cbSize        = sizeof(WNDCLASSEXW);
    wc.style         = CS_HREDRAW | CS_VREDRAW;
    wc.lpfnWndProc   = WndProc;
    wc.hInstance     = hInstance;
    wc.hCursor       = LoadCursorW(nullptr, IDC_ARROW);
    wc.hbrBackground = nullptr;
    wc.lpszClassName = MainWindowClassName;

    return RegisterClassExW(&wc) != 0;
}

HWND CreateMainWindow(HINSTANCE hInstance, int clientWidth, int clientHeight)
{
    const DWORD style = WS_OVERLAPPEDWINDOW;
    const DWORD exStyle = 0;

    RECT rect = ComputeWindowRect(clientWidth, clientHeight, style, exStyle);

    return CreateWindowExW(
        exStyle,
        MainWindowClassName,
        L"DX12 Sandbox",
        style,
        CW_USEDEFAULT,
        CW_USEDEFAULT,
        rect.right - rect.left,
        rect.bottom - rect.top,
        nullptr,
        nullptr,
        hInstance,
        nullptr);
}

int RunRenderLoop()
{
    MSG msg = {};

    while (msg.message != WM_QUIT)
    {
        if (PeekMessageW(&msg, nullptr, 0, 0, PM_REMOVE))
        {
            TranslateMessage(&msg);
            DispatchMessageW(&msg);
        }
        else
        {
            // Later: RenderFrame();
        }
    }

    return static_cast<int>(msg.wParam);
}

LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam)
{
    switch (msg)
    {
        case WM_SIZE:
        {
            const int width = LOWORD(lParam);
            const int height = HIWORD(lParam);

            if (width > 0 && height > 0)
            {
                // Later: resize the DX12 swap chain buffers here.
            }

            return 0;
        }

        case WM_CLOSE:
            DestroyWindow(hwnd);
            return 0;

        case WM_DESTROY:
            PostQuitMessage(0);
            return 0;
    }

    return DefWindowProcW(hwnd, msg, wParam, lParam);
}

int WINAPI wWinMain(
    HINSTANCE hInstance,
    HINSTANCE,
    PWSTR,
    int nCmdShow)
{
    if (!RegisterMainWindowClass(hInstance))
    {
        MessageBoxW(nullptr, L"RegisterClassExW failed.", L"Startup Error", MB_ICONERROR);
        return -1;
    }

    HWND hwnd = CreateMainWindow(hInstance, 1280, 720);
    if (!hwnd)
    {
        MessageBoxW(nullptr, L"CreateWindowExW failed.", L"Startup Error", MB_ICONERROR);
        return -1;
    }

    ShowWindow(hwnd, nCmdShow);
    UpdateWindow(hwnd);

    return RunRenderLoop();
}

Build it as a Windows subsystem application. In Visual Studio, that means the project subsystem should be Windows instead of Console. If you build from the command line with MSVC, the important linker option is:

/SUBSYSTEM:WINDOWS

Common Mistakes

If the window does not appear, check these first:

If the client area is not the size you expected, make sure you used AdjustWindowRectEx with the same style and extended style that you passed to CreateWindowExW.

If the app closes but the process keeps running, make sure WM_DESTROY calls PostQuitMessage.

Why This Is Enough for DX12

DX12 does not need Win32 to draw pixels. It needs Win32 to provide the native window handle:

HWND hwnd = CreateMainWindow(hInstance, 1280, 720);

That HWND is what you will pass into swap chain creation. The swap chain uses it to know which window receives the presented images.

So the work in this article gives us the host. Next, we can create the graphics device and attach rendering to the empty RenderFrame slot in the loop.

Quick Checkpoint

You are ready to move on if these ideas feel solid:

What’s Next

Next up: refactoring this procedural window code into a small Window class so the project has a cleaner shape before we introduce DXGI and DirectX 12.