All tutorials Mighty Professional
Build a Game Engine · The Runtime Spine

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.

Time~50 min LevelBeginner to mid PrereqsThe Game Loop tutorial (we plug its accumulator into the loop here). You can read C++ or Rust. StackC++ (GLFW) · Rust (winit)
◂ Build a Game Engine Phase 3 · The Runtime Spine Next · Input (HID) ▸

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:

A windowing library hands you a window and a native handle, not a renderer

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].

eq. 1 · the Win32 message loop GetMessage / PeekMessage TranslateMessage DispatchMessage WndProc

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.

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).

Service the pump or the OS declares you hung

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:

When the pump drains faster than events arrive, the queue stays short and the window is responsive. Crank the frame cost (a long, blocking frame) past the event rate and the backlog grows every frame; once it represents more than five seconds of unserviced events the title flips to "(Not Responding)" and greys out, exactly the Win32 ghosting rule. Lower the cost and it recovers.

04Poll loop vs event-driven

There are two shapes a platform layer can take, and they decide where your loop lives.

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):

Left, the poll loop spins on its own and renders every iteration; you own the arrow. Right, the framework owns the loop and calls into your handlers. Switch the right side to Wait and, with no events arriving, it sleeps and the animation freezes, which is why a game sets Poll and asks for a redraw each frame. A long update on the left visibly delays the next poll, the same starvation as the previous widget.

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.

The same loop, two control-flow shapes
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].

Rendering is portable; surface creation is platform-specific by design

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].

Create the surface (overview; the swapchain is the Vulkan module)
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.

08Pitfalls

"(Not Responding)"A long frame or a blocking WndProc starves the pump past 5 seconds; the OS ghosts the window.
Freeze while resizingWin32 runs a modal pump inside DispatchMessage; render from WM_PAINT/a timer or another thread.
winit: nothing animatesControlFlow::Wait with no request_redraw(). Use Poll and request a redraw each frame.
winit: deprecated APIThe closure EventLoop::run is gone in 0.30; use run_app + ApplicationHandler.
Surface creation failsGLFW window made with a GL context; create it with GLFW_NO_API for Vulkan.
Blurry on a 4K displayRendering at logical size instead of the physical framebuffer (pixel) size.
Crash off the main threadCreating the window/event loop on a worker thread; keep it on main.

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.

  1. Jason Gregory. Game Engine Architecture, "Human Interface Devices" and the platform/OS layer. gameenginebook.com. The platform-abstraction layer and the OS event model.
  2. Microsoft. "Using Messages and Message Queues." learn.microsoft.com. The GetMessage/TranslateMessage/DispatchMessage loop and that DispatchMessage invokes the window procedure.
  3. Microsoft. "GetMessage function." learn.microsoft.com. Blocks for a message; returns 0 on WM_QUIT, −1 on error (the > 0 idiom).
  4. Microsoft. "PeekMessage function." learn.microsoft.com. Non-blocking message retrieval, the games path that renders every iteration.
  5. Microsoft. "Preventing Hangs in Windows Applications." learn.microsoft.com. The 5-second no-message threshold and the "(Not Responding)" ghost window.
  6. 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.
  7. GLFW. "Window guide." glfw.org. Window creation, the framebuffer-size callback, content scale, and windowed full screen.
  8. GLFW. "Input guide: event processing." glfw.org. glfwPollEvents (the games path) vs glfwWaitEvents (tools).
  9. GLFW. "Vulkan guide." glfw.org. glfwCreateWindowSurface and the GLFW_NO_API requirement.
  10. SDL. "SDL_PollEvent." wiki.libsdl.org. Non-blocking event drain, the per-frame poll-loop pattern, main-thread.
  11. SDL. "Main callbacks in SDL3." wiki.libsdl.org. SDL_AppInit/Iterate/Event/Quit, and why iOS and the web force the callback model.
  12. winit. ApplicationHandler trait. docs.rs/winit. The required resumed/window_event methods and rendering in RedrawRequested (winit 0.30.x).
  13. winit. ControlFlow enum. docs.rs/winit. Poll for games vs Wait (the default) for reactive UIs.
  14. winit. Changelog. docs.rs/winit. ApplicationHandler/run_app added in 0.30.0; EventLoop::run deprecated.
  15. The Khronos Group. Vulkan Specification, Window System Integration (WSI). docs.vulkan.org. VK_KHR_surface plus the platform surface extensions feeding VK_KHR_swapchain.
  16. ash-window. create_surface. docs.rs/ash-window. Creating a VkSurfaceKHR from a raw window/display handle, and the required-extensions helper.
  17. 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.

See also