Building a Rust Game Engine: Part 3 - Logging
Last time we set up engine-owned entry points. Now we can initialize, run, and shutdown applications. But how do we know what’s actually happening inside?
pub fn init() {
println!("GGEngine initialized");
}
This works, but println! isn’t enough for a real engine. We need:
- Severity levels - Distinguish errors from info from debug noise
- Colors - Spot problems instantly in a wall of text
- Timestamps - Know when things happened
- Source tags - Know where messages came from (engine vs. app)
- Strippable in release - Zero overhead in distribution builds
Why Not Write Our Own?
We could write a logging library. But good string formatting-supporting arbitrary types, positional arguments, format specifiers-is substantial work. In Rust, the log crate is the standard facade, and there’s no reason to reinvent it.
We’ll use:
log- The standard Rust logging API (macros likeinfo!,warn!,error!)env_logger- A backend that outputs colored console logs
Setting Up Dependencies
# engine/Cargo.toml
[dependencies]
log = "0.4"
env_logger = "0.11"
Two Logger Categories
Like Hazel’s approach, we want two distinct loggers:
- Core - Engine internal messages tagged
[GGEngine] - Client - Application messages tagged
[App]
This separation matters. When something breaks, you need to know if it’s your code or the engine.
The Log Module
// engine/src/log.rs
use env_logger::Builder;
use log::LevelFilter;
use std::io::Write;
pub fn init() {
Builder::new()
.filter_level(LevelFilter::Trace)
.format(|buf, record| {
use env_logger::fmt::style::{AnsiColor, Style};
let level = record.level();
let style = match level {
log::Level::Error => Style::new().fg_color(Some(AnsiColor::Red.into())).bold(),
log::Level::Warn => Style::new().fg_color(Some(AnsiColor::Yellow.into())),
log::Level::Info => Style::new().fg_color(Some(AnsiColor::Green.into())),
log::Level::Debug => Style::new().fg_color(Some(AnsiColor::Cyan.into())),
log::Level::Trace => Style::new().fg_color(Some(AnsiColor::White.into())),
};
let tag = if record.target().starts_with("GGEngine") {
"GGEngine"
} else {
"App"
};
writeln!(
buf,
"{style}[{timestamp}] [{tag}] {level}: {msg}",
timestamp = buf.timestamp_millis(),
tag = tag,
level = level,
msg = record.args(),
)
})
.init();
}
The custom formatter gives us:
- Colored output based on severity
- Millisecond timestamps
- Tag based on the log target (we’ll set this with macros)
Core Logging Macros
For engine internals, we define macros that set the target to “GGEngine”:
// engine/src/lib.rs
#[macro_export]
macro_rules! core_trace {
($($arg:tt)*) => {
::log::trace!(target: "GGEngine", $($arg)*)
};
}
#[macro_export]
macro_rules! core_info {
($($arg:tt)*) => {
::log::info!(target: "GGEngine", $($arg)*)
};
}
// ... core_debug, core_warn, core_error similarly
The target: parameter tells the log system where this message originated. Our formatter checks for “GGEngine” and tags accordingly.
Client Logging
Client code uses the standard log macros, re-exported through a gg module for a clean namespace:
// engine/src/lib.rs
pub mod gg {
pub use log::{debug, error, info, trace, warn};
}
Clients import use engine::gg and call gg::info!(), etc. These default to the calling module’s path as the target, which won’t start with “GGEngine”, so they get tagged [App].
Initialization Order
Logging must initialize before anything else:
// engine/src/lib.rs
pub fn init() {
log::init(); // First!
core_info!("GGEngine initialized");
}
pub fn shutdown() {
core_info!("GGEngine shutdown");
}
Client Usage
Now applications get clean logging for free:
// sandbox/src/main.rs
use engine::gg;
use engine::Application;
struct Sandbox;
impl Application for Sandbox {
fn new() -> Self {
gg::info!("Sandbox created");
let var = 5;
gg::info!("Hello! var = {}", var);
Sandbox
}
fn run(&mut self) {
gg::trace!("This is a trace message");
gg::debug!("This is a debug message");
gg::info!("This is an info message");
gg::warn!("This is a warning message");
gg::error!("This is an error message");
loop{
}
}
}
engine::entrypoint!(Sandbox);
Running this:
$ cargo xtask run --bin sandbox
[2024-01-25T12:00:00.123Z] [GGEngine] INFO: GGEngine initialized
[2024-01-25T12:00:00.123Z] [App] INFO: Sandbox created
[2024-01-25T12:00:00.123Z] [App] INFO: Hello! var = 5
[2024-01-25T12:00:00.124Z] [App] TRACE: This is a trace message
[2024-01-25T12:00:00.124Z] [App] DEBUG: This is a debug message
[2024-01-25T12:00:00.124Z] [App] INFO: This is an info message
[2024-01-25T12:00:00.124Z] [App] WARN: This is a warning message
[2024-01-25T12:00:00.124Z] [App] ERROR: This is an error message
[2024-01-25T12:00:00.124Z] [GGEngine] INFO: GGEngine shutdown
In a real terminal, errors are red, warnings are yellow, info is green. You can spot problems at a glance.
Stripping Logs in Release
The log crate supports compile-time level filtering. In Cargo.toml:
[features]
max_level_info = ["log/max_level_info"] # Strip trace/debug in release
release_max_level_warn = ["log/release_max_level_warn"] # Only warn+ in release
When enabled, trace and debug calls compile to nothing. Zero overhead.
Why This Approach?
- Standard ecosystem -
logis Rust’s blessed logging facade. Every Rust developer knows it. - Zero cost abstraction - Unused log levels compile away
- Swappable backends - Could switch to
tracingor file logging without changing call sites - Familiar API - Format strings work like
println!
The Log Levels
error!- Something went wrong, needs attentionwarn!- Something suspicious, might be a probleminfo!- Normal operation, useful for understanding flowdebug!- Developer details, stripped in releasetrace!- Extremely verbose, usually disabled
What’s Next
We have entry points and logging. The engine can now communicate what it’s doing. Next, we need a build system that handles:
- Cross-platform builds (Windows, Mac, Linux)
- Automatic DLL copying in debug
- Asset bundling for distribution
This is part 3 of a series on building a game engine in Rust.