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:
- registers its own window class,
- creates a resizable top-level window,
- asks for a real 1280 x 720 client area,
- pumps messages with a render-friendly loop,
- handles resize and close messages cleanly,
- and leaves a clear place for DirectX 12 setup to plug in later.
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:
- An entry point where Windows starts your program.
- A window procedure that receives messages for the window.
- A window class that connects the procedure to a named kind of window.
- A window instance created from that class.
- 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:
HWND hwndidentifies the window receiving the message.UINT msgis the message code, such asWM_SIZEorWM_DESTROY.WPARAMandLPARAMcarry message-specific data.LRESULTis the message-specific result you return to Windows.
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:
lpfnWndProcis the callback that handles messages for windows created from this class.hInstanceidentifies the module registering the class.hCursorgives the window a normal arrow cursor.hbrBackground = nullptrtells Windows not to erase the client area with a GDI brush. For a graphics app, DX12 will own drawing.lpszClassNameis the stable name you pass later toCreateWindowExW.
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:
WM_CLOSEmeans the user or system has requested that the window close.WM_DESTROYmeans the window is actually being destroyed.
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:
RegisterClassExWmust succeed beforeCreateWindowExW.- The class name passed to
CreateWindowExWmust exactly match the registered class name. ShowWindowmust be called after the window is created.- Your message loop must keep running; returning from
wWinMainimmediately exits the process. - Messages you do not handle should go to
DefWindowProcW.
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:
- A window class connects a class name to a
WndProc. CreateWindowExWcreates a window and gives you anHWND.- Client size is not the same as outer window size.
PeekMessageWlets a graphics app render when the message queue is empty.WM_SIZEis where swap chain resizing will eventually start.WM_DESTROYshould postWM_QUITso the loop exits.
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.