Building a Rust Game Engine: Part 4 - Events

We have entry points and logging. Now we need to handle things happening to our engine—window resizing, keyboard input, mouse movement. We need an event system.

Why Events?

An engine without events is blind. When the user presses a key, resizes the window, or clicks the mouse, something needs to know about it. Eventually, our window system will receive these from the OS, construct event objects, and propagate them to the application.

The flow will look like:

OS → Window → Event → Application → Layers

We’re building the middle part today: the event types and how they’re dispatched.

The C++ Approach

In Hazel (C++), events use inheritance and a clever dispatcher:

class Event {
public:
    virtual EventType GetEventType() const = 0;
    bool m_Handled = false;
};

class KeyPressedEvent : public Event {
    static EventType GetStaticType() { return EventType::KeyPressed; }
    // ...
};

class EventDispatcher {
    Event& m_Event;
public:
    template<typename T>
    bool Dispatch(std::function<bool(T&)> func) {
        if (m_Event.GetEventType() == T::GetStaticType()) {
            m_Event.m_Handled = func(static_cast<T&>(m_Event));
            return true;
        }
        return false;
    }
};

Beautiful. You pass around Event&, check the type, and static_cast to the concrete type. The template figures out T::GetStaticType() automatically.

Why Rust Can’t Do This

Rust doesn’t have inheritance. There’s no KeyPressedEvent : Event hierarchy. You can’t hold a &dyn Event and safely cast it to &KeyPressedEvent—that requires Any and runtime type checking.

We could fight the language and build a complex dispatcher with Any and TypeId. Or we could embrace Rust’s strengths: enums and pattern matching.

The Rust Approach: Enums

Instead of inheritance, we use an enum that wraps all event types:

pub enum EventKind {
    WindowClose(WindowCloseEvent),
    WindowResize(WindowResizeEvent),
    KeyPressed(KeyPressedEvent),
    KeyReleased(KeyReleasedEvent),
    MouseMoved(MouseMovedEvent),
    // ... etc
}

Dispatching becomes pattern matching:

fn on_event(event: &mut EventKind) {
    match event {
        EventKind::KeyPressed(e) => {
            println!("Key: {}", e.key_code);
            e.handled = true;
        }
        EventKind::WindowClose(e) => {
            e.handled = true;
        }
        _ => {}
    }
}

No runtime type checks. No unsafe casts. The compiler knows exactly which variant you’re handling.

Event Types

We define an enum for all event types:

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EventType {
    None,
    // Window events
    WindowClose,
    WindowResize,
    WindowFocus,
    WindowLostFocus,
    WindowMoved,
    // Application events
    AppTick,
    AppUpdate,
    AppRender,
    // Key events
    KeyPressed,
    KeyReleased,
    KeyTyped,
    // Mouse events
    MouseButtonPressed,
    MouseButtonReleased,
    MouseMoved,
    MouseScrolled,
}

Event Categories

Sometimes you want to filter events. “Give me all input events” or “ignore mouse events.” Categories are bitflags that can be combined:

use bitflags::bitflags;

bitflags! {
    pub struct EventCategory: u32 {
        const NONE         = 0;
        const APPLICATION  = 1 << 0;
        const INPUT        = 1 << 1;
        const KEYBOARD     = 1 << 2;
        const MOUSE        = 1 << 3;
        const MOUSE_BUTTON = 1 << 4;
    }
}

A KeyPressedEvent is both KEYBOARD and INPUT. A MouseButtonPressedEvent is MOUSE, MOUSE_BUTTON, and INPUT. This lets you write:

if event.is_in_category(EventCategory::INPUT) {
    // Handle any input event
}

The Event Trait

Each concrete event implements a common trait:

pub trait Event: Debug + Display {
    fn event_type(&self) -> EventType;
    fn category_flags(&self) -> EventCategory;
    fn name(&self) -> &'static str;
    fn handled(&self) -> bool;
    fn set_handled(&mut self, handled: bool);

    fn is_in_category(&self, category: EventCategory) -> bool {
        self.category_flags().contains(category)
    }
}

Concrete Events

Each event is a simple struct with its data:

#[derive(Debug, Clone)]
pub struct KeyPressedEvent {
    pub key_code: KeyCode,
    pub repeat_count: u32,
    pub handled: bool,
}

impl KeyPressedEvent {
    pub fn new(key_code: KeyCode, repeat_count: u32) -> Self {
        Self { key_code, repeat_count, handled: false }
    }
}

impl Display for KeyPressedEvent {
    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
        write!(f, "KeyPressedEvent: {} ({} repeats)", self.key_code, self.repeat_count)
    }
}

impl Event for KeyPressedEvent {
    fn event_type(&self) -> EventType { EventType::KeyPressed }
    fn category_flags(&self) -> EventCategory {
        EventCategory::KEYBOARD | EventCategory::INPUT
    }
    fn name(&self) -> &'static str { "KeyPressed" }
    fn handled(&self) -> bool { self.handled }
    fn set_handled(&mut self, handled: bool) { self.handled = handled; }
}

Yes, there’s boilerplate. Each event needs similar impl blocks. A macro could reduce this, but explicit code is easier to understand and debug.

The Full Event List

Key events:

  • KeyPressedEvent - Key pressed (with repeat count)
  • KeyReleasedEvent - Key released
  • KeyTypedEvent - Character typed

Mouse events:

  • MouseMovedEvent - Mouse position changed (x, y)
  • MouseScrolledEvent - Scroll wheel (x_offset, y_offset)
  • MouseButtonPressedEvent - Mouse button down
  • MouseButtonReleasedEvent - Mouse button up

Application events:

  • WindowCloseEvent - Window close button clicked
  • WindowResizeEvent - Window resized (width, height)
  • AppTickEvent - Application tick
  • AppUpdateEvent - Application update
  • AppRenderEvent - Application render

Module Structure

engine/src/ggengine/events/
├── mod.rs              # Re-exports everything
├── event.rs            # Event trait, EventType, EventCategory
├── key_events.rs       # KeyPressedEvent, KeyReleasedEvent, KeyTypedEvent
├── mouse_events.rs     # Mouse events
├── application_events.rs # Window and app events
└── dispatcher.rs       # EventKind enum

Usage Example

use engine::events::{EventKind, KeyPressedEvent, EventCategory};

struct Game {
    running: bool,
}

impl Game {
    fn on_event(&mut self, event: &mut EventKind) {
        // Filter by category
        if !event.is_in_category(EventCategory::INPUT) {
            return;
        }

        // Pattern match to handle specific events
        match event {
            EventKind::KeyPressed(e) => {
                if e.key_code == 27 { // ESC
                    self.running = false;
                }
                e.handled = true;
            }
            EventKind::WindowClose(e) => {
                self.running = false;
                e.handled = true;
            }
            _ => {}
        }
    }
}

Trade-offs vs C++

Advantages of the Rust approach:

  • No heap allocation—events live on the stack
  • No virtual dispatch overhead
  • Exhaustive matching—compiler warns if you miss a variant
  • No unsafe casts

Disadvantages:

  • Adding new event types requires modifying the EventKind enum
  • More boilerplate per event type
  • Can’t easily extend from outside the engine

The enum approach is idiomatic Rust. Fighting for inheritance would make the code harder to understand for Rust developers while providing minimal benefit.

What’s Next

We have events but nothing generating them. Next, we need a window system that:

  1. Creates an OS window
  2. Receives OS events (key presses, mouse moves, etc.)
  3. Translates them to our event types
  4. Calls back to the application with EventKind

The window will hold a callback function. When an event occurs, it constructs the appropriate event and invokes the callback. The application sets this callback when creating the window.


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