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 like info!, 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:

  1. Core - Engine internal messages tagged [GGEngine]
  2. 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?

  1. Standard ecosystem - log is Rust’s blessed logging facade. Every Rust developer knows it.
  2. Zero cost abstraction - Unused log levels compile away
  3. Swappable backends - Could switch to tracing or file logging without changing call sites
  4. Familiar API - Format strings work like println!

The Log Levels

  • error! - Something went wrong, needs attention
  • warn! - Something suspicious, might be a problem
  • info! - Normal operation, useful for understanding flow
  • debug! - Developer details, stripped in release
  • trace! - 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.