Building a Rust Game Engine: Part 5 - Window Abstraction

Following the Cherno’s game engine series, we’ve implemented window abstraction - the foundation for displaying graphics.

Design Philosophy

As Cherno discusses, there’s a balance between:

  • Software engineering: Logging, events, and other systems should come before windowing
  • Entertainment/Education: A visible window keeps motivation high

We’ve built logging and events first, and now we can create windows that integrate with our event system.

Platform Abstraction

The key insight is that while we use winit (the Rust equivalent of GLFW) for cross-platform windowing, we still want:

  1. An abstract Window trait - platform-independent interface
  2. Platform-specific implementations - currently winit, but we could add native Win32/DirectX support later

This mirrors Hazel’s architecture where there’s a Window base class and platform-specific implementations.

Architecture

engine/src/ggengine/
  window.rs           # Abstract Window trait + WindowProps
  platform/
    mod.rs            # Platform module, create_window() factory
    winit_window.rs   # Winit-based implementation

Window Trait

The abstract Window trait defines what any window must provide:

pub trait Window {
    fn on_update(&mut self);
    fn width(&self) -> u32;
    fn height(&self) -> u32;
    fn set_event_callback(&mut self, callback: EventCallbackFn);
    fn set_vsync(&mut self, enabled: bool);
    fn is_vsync(&self) -> bool;
    fn should_close(&self) -> bool;
}

WindowProps

Similar to Hazel, we have a properties struct for window creation:

pub struct WindowProps {
    pub title: String,
    pub width: u32,
    pub height: u32,
}

impl Default for WindowProps {
    fn default() -> Self {
        Self {
            title: "GGEngine".to_string(),
            width: 1280,
            height: 720,
        }
    }
}

Winit vs GLFW

In the Rust ecosystem, winit is the standard windowing library (rather than GLFW):

  • Cross-platform: Windows, macOS, Linux, and more
  • Modern event loop: Uses an application handler pattern
  • Well-maintained: Active community and updates

One key difference from GLFW is that winit “owns” the event loop through run_app(). We handle this with:

pub fn run_window_loop(window: &mut WinitWindow, mut app_tick: impl FnMut()) {
    let event_loop = window.event_loop.take().expect("Event loop already consumed");

    let mut app = WinitApp::new(window, Some(Box::new(move || {
        app_tick();
    })));

    event_loop.run_app(&mut app).expect("Event loop error");
}

Event Integration

The window implementation converts winit events to our engine’s event types:

fn handle_window_event(&mut self, event: WindowEvent) {
    match event {
        WindowEvent::CloseRequested => {
            self.window.data.should_close = true;
            WinitWindow::dispatch_event(
                &mut self.window.data,
                EventKind::WindowClose(WindowCloseEvent::new()),
            );
        }
        WindowEvent::Resized(PhysicalSize { width, height }) => {
            self.window.data.width = width;
            self.window.data.height = height;
            WinitWindow::dispatch_event(
                &mut self.window.data,
                EventKind::WindowResize(WindowResizeEvent::new(width, height)),
            );
        }
        // ... keyboard, mouse events
    }
}

Key Code Mapping

We upgraded KeyCode from a simple u32 to a proper enum:

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[repr(u32)]
pub enum KeyCode {
    Unknown = 0,
    Space = 32,
    // A-Z, 0-9, F1-F24, etc.
    A = 65,
    Escape = 256,
    LeftShift = 340,
    // ...
}

This provides type safety and proper debug output when logging key events.

Usage Example

use engine::{Window, WindowProps};
use engine::platform::{create_winit_window, run_window_loop};
use engine::events::EventKind;

fn run(&mut self) {
    let props = WindowProps::new("Sandbox", 1280, 720);
    let mut window = create_winit_window(props);

    window.set_event_callback(Box::new(|event| {
        match &event {
            EventKind::WindowClose(_) => {
                gg::info!("Window close requested");
            }
            EventKind::KeyPressed(e) => {
                gg::trace!("Key: {:?}", e.key_code);
            }
            _ => {}
        }
    }));

    run_window_loop(&mut window, || {
        // Frame callback - rendering goes here
    });
}

Output

Running the sandbox now shows:

[GGEngine] INFO: GGEngine initialized
[App] INFO: Sandbox created
[App] INFO: Creating window...
[GGEngine] INFO: Creating window Sandbox (1280x720)
[GGEngine] INFO: Window created successfully
[App] INFO: Window resized to 1600x900

And when closing:

[App] INFO: Window close requested
[App] INFO: Window closed, shutting down
[GGEngine] INFO: Shutting down window
[GGEngine] INFO: GGEngine shutdown

What’s Next

With the window system in place, we can:

  1. Fully integrate events (next episode, as Cherno suggests)
  2. Add a layer system for organizing application logic
  3. Start setting up an OpenGL/rendering context

The foundation is now ready for graphics!


This is part 5 of a series on building a game engine in Rust.