Platform & Window: the OS Event Loop
Before a triangle can appear, something has to ask the operating system for a window, pump its event queue, and hand the GPU a drawable surface. That platform layer is small but full of sharp edges: a message pump you must service or the OS calls you hung, a resize that runs its own loop behind your back, and two completely different control-flow shapes depending on which library you use. We build it in C++ and Rust.
01The platform layer
The operating system owns the window, the input devices, and the stream of events. Your engine borrows a drawable region and a feed of events, and each OS exposes that through a different native API: Win32 on Windows, X11 and Wayland on Linux, Cocoa/AppKit on macOS[1].
Engines wrap those differences behind a thin platform layer so the rest of the codebase sees one window type and one event type. The libraries that are that layer for most projects are SDL, GLFW, and winit. The framing to hold for the whole tutorial:
GLFW, SDL, and winit give you a window, an event stream, and an opaque OS handle. They do not draw anything. You bring Vulkan (or OpenGL, or Direct3D) yourself and connect it to that handle. Keeping the window and the renderer decoupled is what lets a Rust engine swap winit for SDL without touching its Vulkan code (§6).
02Creating a window
Underneath, every platform does the same three things: describe the window, create it, and get back an opaque handle (HWND on Windows, an X11 window id, a Wayland surface, an NSWindow). A library normalizes that into one call.
The handle is the thing you hand to the GPU later for surface creation, so creating the window and creating the renderer are two separate steps even though they feel like one.
03The OS event model
The OS doesn't call your code when a key goes down. It puts a message in a queue and waits for you to come get it. On Windows that's the : retrieve a message, translate it, dispatch it to your window procedure[2].
Read it left to right: pull a message off the queue (GetMessage blocks, PeekMessage does not), optionally turn key presses into characters, then dispatch it so the OS calls your WndProc. The arrows are "then", not arithmetic; this is a pipeline of four Win32 calls, the one loop every Windows window runs. Hover any symbol to see what it stands for.
GetMessageblocks until a message arrives and returns 0 onWM_QUIT(and −1 on error, which is why the idiom iswhile (GetMessage(...) > 0))[3]. Games usePeekMessageinstead, which is non-blocking, so the loop can render every iteration whether or not events are waiting[4].TranslateMessageonly turns keyboard virtual-key messages into character messages; it does not "translate all messages."DispatchMessagecalls yourWndProcfor that message and returns when it returns[2].
Linux and macOS differ in detail but not in spirit. X11 is a connection you pull events off; Wayland is a protocol where the compositor requests a size and the client must acknowledge it. macOS's NSApplication owns its run loop and dispatches NSEvents, which is one of the platforms (iOS and the web are the others) that pushes the cross-platform Rust library (winit) to own your loop too (§4).
If a top-level window doesn't retrieve a message for 5 seconds, the Desktop Window Manager marks it unresponsive, snapshots it, and replaces it with a ghost window with "(Not Responding)" in the title. The threshold is not configurable[5]. The OS can't tell "busy" from "hung"; it only knows messages aren't being retrieved. A single long frame, or a blocking call inside WndProc, is enough.
The widget is the pump. Events arrive into the queue; the pump drains them to handlers; a frame boundary fires. Starve the pump and watch the backlog build:
04Poll loop vs event-driven
There are two shapes a platform layer can take, and they decide where your loop lives.
- Owned poll loop (SDL, GLFW): you own
while (running). Each frame you call poll-events to drain the queue, then update and render. The library is a service you call[8][10]. - (winit; also SDL3's main-callback mode): the framework owns the loop and calls you. You implement handlers and hand control back every iteration[12][11].
The callback shape exists because some platforms (iOS, the web, increasingly macOS) require the system to own the loop and call into you. The poll loop is the convenient shape where the platform allows it. GLFW frames its choice plainly: glfwPollEvents "processes only those events that have already been received and then returns immediately... the best choice when rendering continuously, like most games do," while glfwWaitEvents sleeps until something happens, which suits tools and editors[8].
The widget feeds the same event stream into both shapes. Toggle who owns the loop, and switch the event-driven side between continuous (Poll) and sleep-until-event (Wait):
Wrong answers, and why: the resize freeze is the modal message loop, not a GPU limit or a cleared queue; and in current winit you render in RedrawRequested via run_app, not in about_to_wait or the deprecated run closure.
05Plugging in the fixed timestep
The accumulator from the Game Loop tutorial drops into both shapes. The algorithm is identical; only where the state lives and who calls the body change.
Need a refresher on the fixed-timestep accumulator?
Real frames arrive at uneven intervals, but physics is most stable when stepped by a constant dt (here 1/60 s). The fix: each frame, add the elapsed wall-clock time to an accumulator, then run update(dt) in a loop while the accumulator holds at least one whole dt, subtracting dt each pass. Simulation advances in fixed chunks no matter how the frame rate wobbles.
The leftover in the accumulator (less than one dt) is how far you are between two simulation steps. Passing accumulator / dt to render as an interpolation factor lets the picture sit smoothly between the last two states instead of snapping to the most recent one. The Game Loop tutorial derives all of this.
glfwInit();
glfwWindowHint(GLFW_CLIENT_API, GLFW_NO_API); // no GL context; required before glfwCreateWindowSurface
GLFWwindow* window = glfwCreateWindow(1280, 720, "Engine", nullptr, nullptr);
const double dt = 1.0 / 60.0; // fixed step, from the Game Loop module
double accumulator = 0.0, currentTime = glfwGetTime();
while (!glfwWindowShouldClose(window)) { // YOU own the loop
glfwPollEvents(); // drain OS events now, non-blocking
double newTime = glfwGetTime();
double frameTime = newTime - currentTime;
currentTime = newTime;
accumulator += frameTime;
while (accumulator >= dt) { update(dt); accumulator -= dt; }
render(accumulator / dt); // interpolation alpha
}
// winit 0.30.x: the framework owns the loop and calls your handler.
// EventLoop::run is deprecated; use run_app. Render in RedrawRequested.
struct App { window: Option<Window>, dt: f64, accumulator: f64, current_time: Instant }
impl ApplicationHandler for App {
fn resumed(&mut self, el: &ActiveEventLoop) { // create the window here
self.window = Some(el.create_window(Window::default_attributes()).unwrap());
self.current_time = Instant::now();
}
fn window_event(&mut self, el: &ActiveEventLoop, _id: WindowId, ev: WindowEvent) {
match ev {
WindowEvent::CloseRequested => el.exit(),
WindowEvent::RedrawRequested => { // the per-frame body
let now = Instant::now();
let frame_time = (now - self.current_time).as_secs_f64();
self.current_time = now;
self.accumulator += frame_time;
while self.accumulator >= self.dt { update(self.dt); self.accumulator -= self.dt; }
render(self.accumulator / self.dt);
}
_ => {}
}
}
fn about_to_wait(&mut self, _el: &ActiveEventLoop) {
if let Some(w) = &self.window { w.request_redraw(); } // keep frames flowing under Poll
}
}
// main: EventLoop::new() (main thread!); set_control_flow(ControlFlow::Poll); run_app(&mut app)
That diff is the whole lesson: in GLFW you call the loop body inside your while; in winit the body is a method (RedrawRequested) and its state lives on the struct, because winit calls you. The accumulator math is byte-for-byte the same.
06Handing a surface to the GPU
Vulkan's core is windowing-agnostic; presentation comes through the WSI extensions. The cross-platform VK_KHR_surface defines the VkSurfaceKHR type, and one platform extension (VK_KHR_win32_surface, VK_KHR_xcb_surface, VK_KHR_wayland_surface, a Metal surface on macOS) actually creates it from the native handle[15].
Both halves are true at once. Once the exists, the and all the drawing are platform-independent, but the call that makes the surface is tied to the OS. The libraries hide it: GLFW's glfwCreateWindowSurface picks the right extension for you (and requires the window be created with GLFW_NO_API, proof again that GLFW gives you a surface, not a renderer)[9]. In Rust, raw-window-handle decouples the window from the GPU library, and ash-window creates the surface from that handle[17][16].
VkSurfaceKHR surface;
// GLFW selects VK_KHR_win32_surface / _xcb_ / _wayland_ for you.
if (glfwCreateWindowSurface(instance, window, nullptr, &surface) != VK_SUCCESS)
throw std::runtime_error("failed to create surface");
// Required instance extensions come from the display handle; enable them first.
let ext = ash_window::enumerate_required_extensions(window.display_handle()?.as_raw())?;
// create_surface is unsafe and the returned surface has NO Drop; destroy it yourself.
let surface = unsafe {
ash_window::create_surface(&entry, &instance,
window.display_handle()?.as_raw(), window.window_handle()?.as_raw(), None)?
};
07Resize, DPI, threads
Three loose ends the platform layer surfaces and the rest of the engine has to respect.
- Resize. The window reports a new size (Win32
WM_SIZE; winitWindowEvent::Resized). The Vulkan swapchain is sized to the old extent and goes stale, so it must be recreated. The mechanism (handlingVK_ERROR_OUT_OF_DATE_KHR) belongs to the Vulkan module; here, just know resize is an event that invalidates GPU-side state, and on Win32 it arrives inside that modal loop from §3[6]. - DPI. There are two sizes: the logical window size in screen coordinates, and the physical framebuffer size in pixels. They differ on high-DPI displays. Render targets and viewports use pixels (the framebuffer size), not logical size, or everything is blurry or clipped[7].
- Threads. Window creation and event handling belong on the main thread; macOS's AppKit requires it, and winit panics if you create the event loop elsewhere. Rendering can move to other threads, but the window and its events stay on main.
08Pitfalls
WndProc starves the pump past 5 seconds; the OS ghosts the window.DispatchMessage; render from WM_PAINT/a timer or another thread.ControlFlow::Wait with no request_redraw(). Use Poll and request a redraw each frame.EventLoop::run is gone in 0.30; use run_app + ApplicationHandler.GLFW_NO_API for Vulkan.09What's next
The window is open and the loop is running. Input (HID) is next: turning the raw key and gamepad events this layer delivers into actions, dead zones, and buffers. After that, the GPU pipeline and your first Vulkan triangle, where the surface created in §6 finally gets a swapchain and something to draw. The full path is on the series hub.
- Jason Gregory. Game Engine Architecture, "Human Interface Devices" and the platform/OS layer. gameenginebook.com. The platform-abstraction layer and the OS event model.
- Microsoft. "Using Messages and Message Queues." learn.microsoft.com. The
GetMessage/TranslateMessage/DispatchMessageloop and thatDispatchMessageinvokes the window procedure. - Microsoft. "GetMessage function." learn.microsoft.com. Blocks for a message; returns 0 on
WM_QUIT, −1 on error (the> 0idiom). - Microsoft. "PeekMessage function." learn.microsoft.com. Non-blocking message retrieval, the games path that renders every iteration.
- Microsoft. "Preventing Hangs in Windows Applications." learn.microsoft.com. The 5-second no-message threshold and the "(Not Responding)" ghost window.
- Raymond Chen. "Thread messages are eaten by modal loops," The Old New Thing. devblogs.microsoft.com. Resize/move/menu modal loops run their own pump and starve your loop.
- GLFW. "Window guide." glfw.org. Window creation, the framebuffer-size callback, content scale, and windowed full screen.
- GLFW. "Input guide: event processing." glfw.org.
glfwPollEvents(the games path) vsglfwWaitEvents(tools). - GLFW. "Vulkan guide." glfw.org.
glfwCreateWindowSurfaceand theGLFW_NO_APIrequirement. - SDL. "SDL_PollEvent." wiki.libsdl.org. Non-blocking event drain, the per-frame poll-loop pattern, main-thread.
- SDL. "Main callbacks in SDL3." wiki.libsdl.org.
SDL_AppInit/Iterate/Event/Quit, and why iOS and the web force the callback model. - winit. ApplicationHandler trait. docs.rs/winit. The required
resumed/window_eventmethods and rendering inRedrawRequested(winit 0.30.x). - winit. ControlFlow enum. docs.rs/winit.
Pollfor games vsWait(the default) for reactive UIs. - winit. Changelog. docs.rs/winit.
ApplicationHandler/run_appadded in 0.30.0;EventLoop::rundeprecated. - The Khronos Group. Vulkan Specification, Window System Integration (WSI). docs.vulkan.org.
VK_KHR_surfaceplus the platform surface extensions feedingVK_KHR_swapchain. - ash-window. create_surface. docs.rs/ash-window. Creating a
VkSurfaceKHRfrom a raw window/display handle, and the required-extensions helper. - raw-window-handle. github.com/rust-windowing/raw-window-handle. The provider/consumer split (winit/SDL/GLFW provide handles; wgpu/ash consume them) that decouples windowing from the GPU library.