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.