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.