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 releasedKeyTypedEvent- Character typed
Mouse events:
MouseMovedEvent- Mouse position changed (x, y)MouseScrolledEvent- Scroll wheel (x_offset, y_offset)MouseButtonPressedEvent- Mouse button downMouseButtonReleasedEvent- Mouse button up
Application events:
WindowCloseEvent- Window close button clickedWindowResizeEvent- Window resized (width, height)AppTickEvent- Application tickAppUpdateEvent- Application updateAppRenderEvent- 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
EventKindenum - 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:
- Creates an OS window
- Receives OS events (key presses, mouse moves, etc.)
- Translates them to our event types
- 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.