shisaku ~/devlog
$ auth github

Author sign in

Sign in with GitHub to comment on devlog entries.

Continue with GitHub
← all posts

Devlog #5 — WTF, Why Rust?

Why EchoWarrior is written in Rust instead of C/C++, and what that actually felt like so far.

Focus: Why EchoWarrior is written in Rust instead of C/C++, and what that actually felt like so far.


Why was this game destined to be written in Rust?

A case study against C and C++ as I am an experienced C programmer, a little less experienced C++ programmer, and a Rust beginner.


What Rust is when you just try to ship something

If you only read blog posts, Rust sounds almost mythical because the community is a little deranged: memory safety without a GC, fearless concurrency, modern systems language, all the buzzwords.

None of those slogans were what mattered at midnight, after a hospital shift, staring at a compile error with a baby monitor next to the keyboard.

For EchoWarrior, Rust boiled down to three very practical things:

So imagine C or C++ with a compiler that yells at you for a specific reason, not because of segfaults, PLUS a package manager that makes it easy to pull in libraries for boring stuff. That is what Rust currently is and it does its job.

Underneath the marketing, Rust assumes the programmer will be tired and distracted and designs the language around that assumption. For a game that is explicitly built to be taken apart and edited by people who are not writing Rust, that attitude turned out to be extremely fucking damn crucial after a dream or two.


The Rust Tax: 12 Seconds vs One Second

Start with the thing Rust is bad at: iteration speed lol.

On this project, a full debug rebuild of the EchoWarrior crate takes around twelve seconds. That is “fast” by Rust standards. It is an eternity when you’re trying to tune a spawn curve and your brain keeps leaving the room during each compile.

Early on, every balance tweak meant:

That pain is what forced the data-driven turn so hard. Enemies, player stats, spawn waves, UI, shaders, companions – all of it migrated into TOML and YAML specifically so that tuning could happen without waiting for the compiler every time.

In C or C++, compile times can be better, but the temptation is strong to hardcode values in code because it’s “just one more constant”. Rust’s compile-time tax made hardcoding feel bad enough that it pushed everything out into data.

So a very simple rule emerged:

That is Rust in practice: strict up front, then aggressively offloading everything that doesn’t need that strictness into data.


The Borrow Checker vs Hot-Reload: Three Evenings and a Teething Baby

Hot-reloading Lua scripts was refreshingly straightforward: recompile the chunk, swap it into the Lua state, let the garbage collector clean up old references. No borrow checker, no lifetime nightmares.

Hot-reloading Rust data into live structs was the opposite.

The problem: when ui.toml changes on disk, the game should reload colours, labels, layout numbers, and theme values while the game is running. The catch is that these values are read on every frame. If you naively grab a write lock and swap in new data while the renderer is reading, you get stutters or deadlocks.

Rust will not let you “just wing it” here. It forced a design:

Getting this right took three evenings, a lot of thread-sanitizer output, and at least one session where the baby was teething and my brain was mush. But the result is exactly what modders need:

Would C or C++ let you build something similar? Of course. But C and C++ would happily let you build it wrong too: raw pointers into structs that get freed, missing locks on paths you thought were “safe”, malformed files causing UB instead of logged defaults.

Rust’s borrow checker was painful in the moment, but it forced a hot-reload design that matches the philosophy of the game: people will break things; the system should bend, not snap.


The Python Plan, Windows AppLocker, and Why Lua Won

Originally, the plan was to embed Python.

The reasoning was simple:

Reality:

Two days vanished into trying to dodge group policy:

None of it worked on the primary Windows machine. The conclusion was very simple and very Rust-flavoured: a scripting language that does not reliably compile on your target platform is useless.

Lua, via mlua, compiled as a C library with no build scripts, no AppLocker drama, and no surprises. It built on the first cargo build and has not caused a single build problem since.

Yes, that choice means modders need to learn Lua instead of Python. But Lua is small and focused:

You can learn enough Lua to write spawn logic in an afternoon. And more importantly, Lua builds. Everywhere. All the time.

Rust didn’t just influence the choice of game language; it influenced the choice of scripting language. The same bias shows up again: reliability beats popularity when the whole point is “you are allowed to change this”.


The Import Wall: Rust’s Architecture Push vs C/C++

One of the most important decisions in EchoWarrior is a line you never see in gameplay: the boundary between src/game and src/runtime.

That separation isn’t uniquely Rust. You can absolutely write a renderer-agnostic game core in C or C++. But Rust’s module system and type discipline make the clean split feel natural:

The side-effects are powerful:

In C or C++, it’s very easy for “just one render call” to slip into core logic, and suddenly your data layer knows about textures and cameras. Rust didn’t magically prevent that, but it did make it much clearer where those boundaries should be, and made violations feel obviously wrong.


Data-Driven Design, Graceful Degradation, and Why Rust Helped

By now, EchoWarrior has:

Every loader follows the same boring pattern:

The boringness is intentional. It’s “defensive architecture” written for the moment when:

In C, that level of graceful degradation is possible, but never automatic. In Rust, serde(default) plus Default implementations and Result handling make it the path of least resistance.

From a general Rust vs C/C++ perspective, this is one of the real differences:

For a game that is explicitly designed so a non-programmer friend can open player.toml, change movespeed to 500, and watch the character zoom around without ever seeing a stack trace, that push matters.


So Why Rust, Not C or C++?

There’s no single dramatic reason. Instead, it’s a stack of small ones that all point the same way:

Could EchoWarrior have been built in C or C++? Absolutely. It might even compile faster. But it would have been much easier to accidentally build a game that punishes curiosity: one typo in a config, and the whole thing falls over.

Rust helped push every major decision toward the opposite outcome:

That, more than any benchmark or slogan, is why this game is written in Rust.


Concrete code examples

#[derive(Deserialize, Default)]
struct PlayerConfig {
    #[serde(default = "default_movespeed")]
    movespeed: f32,
    #[serde(default)]
    health: u32,
}
fn load_player_config(path: &str) -> PlayerConfig {
    let file = std::fs::File::open(path).unwrap_or_else(|_| {
        log::warn!("Could not open {}, using defaults", path);
        return std::fs::File::open("default_player.toml").unwrap();
    });
    let config: Result<PlayerConfig, _> = toml::from_reader(file);
    match config {
        Ok(cfg) => cfg,
        Err(e) => {
            log::error!("Failed to parse {}, using defaults: {}", path, e);
            PlayerConfig::default()
        }
    }
}

Versus:

struct PlayerConfig {
    float movespeed = 5.0f;
    unsigned int health = 100;
};

PlayerConfig load_player_config(const std::string& path) {
    std::ifstream file(path);
    if (!file.is_open()) {
        std::cerr << "Could not open " << path << ", using defaults" << std::endl;
        return PlayerConfig(); // default
    }
    PlayerConfig config;
    // Pseudo-code for parsing TOML in C++
    if (!parse_toml(file, config)) {
        std::cerr << "Failed to parse " << path << ", using defaults" << std::endl;
        return PlayerConfig(); // default
    }
    return config;
}

Another example why it actually matters: the Lua hot-reload system. In Rust, you can safely swap in new data while the game is running, thanks to the borrow checker and careful design. In C/C++, you might end up with dangling pointers or race conditions if you try to do the same thing without a lot of extra care. Rust’s strictness upfront leads to safer, more robust systems in practice, especially in a modding-friendly environment like EchoWarrior.

fn reload_ui_config() {
    if ui_config_dirty {
        let new_config = load_ui_config("ui.toml");
        let mut config_lock = ui_config.write().unwrap();
        *config_lock = new_config;
        ui_config_dirty = false;
    }
}

C/C++ equivalent might look like this, but it’s more error-prone:

void reload_ui_config() {
    if (ui_config_dirty) {
        PlayerConfig new_config = load_ui_config("ui.toml");
        std::lock_guard<std::mutex> lock(ui_config_mutex);
        ui_config = new_config; // Potentially unsafe if other threads are reading
        ui_config_dirty = false;
    }
}

And one with heap allocation:

fn load_enemy_definitions() -> Vec<EnemyDef> {
    let file = std::fs::File::open("enemies.toml").unwrap();
    let defs: Result<Vec<EnemyDef>, _> = toml::from_reader(file);
    match defs {
        Ok(d) => d,
        Err(e) => {
            log::error!("Failed to parse enemies.toml: {}", e);
            Vec::new() // Return empty vector on error
        }
    }
}
std::vector<EnemyDef> load_enemy_definitions() {
    std::ifstream file("enemies.toml");
    if (!file.is_open()) {
        std::cerr << "Could not open enemies.toml" << std::endl;
        return std::vector<EnemyDef>(); // Return empty vector
    }
    std::vector<EnemyDef> defs;
    // Pseudo-code for parsing TOML in C++
    if (!parse_toml(file, defs)) {
        std::cerr << "Failed to parse enemies.toml" << std::endl;
        return std::vector<EnemyDef>(); // Return empty vector on error
    }
    return defs;
}

Why is the Rust version safer? Because it uses Result and unwrap_or_else to handle errors with a degree of “grace”, while the C++ version relies on manual error checking and can easily lead to undefined behavior if not handled correctly. Again you can always argue skill issue but I just don’t remember C++ having a package manager, with all the reasons above. It’s safe yes but also a lot of work.

But! We would have to write a lot more code in C++ to get the same level of “safety” and error handling that Rust forces you into. If you are forced to go on a diet and exercise every day, would you call it “stripping freedoms?”

The borrow checker, type system, and ecosystem of crates make it easier to write robust code without having to reinvent the wheel for every little thing.

The ECS system was installed with a crate, the TOML parsing was done with a crate, the Lua embedding was done with a crate, and so on. In C/C++, you would have to either write your own or find a library and deal with linking issues, which is a whole other can of worms.

$ comments

Reader notes

0 notes

No notes yet.

If you're reading this as a developer: this devlog is built in the open.

If you're reading this as a modder: the direction is source-visible, inspectable systems.