Exploring Modding Systems: A Journey With Lua and Rust

Create flexible and high-performance modding systems

Stefan Kupresak
Better Programming

--

Foto: Pixbay

In the ever-evolving world of game development, modding has become a powerful way to enhance gameplay experiences and breathe new life into existing titles. It allows creators and players to bring their unique ideas to life, enriching the gaming ecosystem with fresh content and endless possibilities. As developers, we’re always looking for innovative ways to build and support modding systems that are both efficient and user-friendly. Today, we embark on a captivating journey to explore the synergy between two remarkable technologies, Lua and Rust, as we delve into the realm of modding systems.

Lua, a lightweight and versatile scripting language, has long been a popular choice for game developers to implement modding support. Its flexibility and ease of use make it an ideal candidate for extending game functionality and allowing user-generated content. On the other hand, Rust, a systems programming language focused on safety and performance, has quickly gained momentum for its powerful features and impressive capabilities. By combining the best of both worlds, we set out to create a robust, yet accessible modding system that opens the door to endless creativity.

Join us as we experiment with the unique strengths of Lua and Rust, sharing our insights, challenges, and triumphs along the way. Whether you’re a seasoned developer or just starting your journey in game development, this adventure promises to be an exciting exploration of modding systems and the potential of Lua and Rust.

Making a cargo project and integrating Lua

Let’s embark on this journey by creating a new Cargo project and setting up a simple Lua script.

First, create a new Cargo project and navigate to the project directory:

cargo new modding-example && cd modding-example

Next, add the rlua crate to your project:

cargo add rlua

Now, let’s create a main.rs file with the following code to execute a Lua script:

// File: src/main.rs
use rlua::{Lua, Result};
use std::fs;

fn exec_lua_code() -> Result<()> {
let lua_code = fs::read_to_string("game/main.lua").expect("Unable to read the Lua script");

let lua = Lua::new();
lua.context(|lua_ctx| {
lua_ctx.load(&lua_code).exec()?;

Ok(())
})
}

fn main() -> Result<()> {
exec_lua_code()
}

If we try to run this, we’ll notice that the script can’t execute because the game/main.lua file doesn't exist yet. Let's create the necessary directory and file:

mkdir game
touch game/main.lua

Now, let’s add some flair to our Lua script by printing some information:

-- File: game/main.lua
print(_VERSION)
print("🌙 Lua is working!")

With this setup, you should see the Lua version and the “Lua is working!” message printed to the console when you run your Rust program.

Making the Stdout More Interesting

Our texts look plain and we can’t differentiate between lua outputs and rust outputs. Let’s change that, by utilizing colors from the popular colored crate.

First, let’s add colored crate to our project:

cargo add colored

Now let’s re-implement Lua’s print statement with our own.

-- File: game/main.lua

function print(...)
local args = {...}

for _, arg in ipairs(args) do
if type(arg) == "table" then
__rust_bindings_print(tostring(arg))
elseif type(arg) == "string" then
__rust_bindings_print(arg)
else
__rust_bindings_print(tostring(arg))
end
end
end

-- rest of game/main.lua

Now, we need to pass __rust_bindings_print to the lua context, so it calls our rust code. We are also going to create a log function for our rust runtime:

// File: src/main.rs
use rlua::{Lua, Result};
use std::fs;

mod logger;

/* rest of main.rs */

And now we must create this file:

touch src/logger.rs

Finally, we can implement the colorized output:

// File: src/logger.rs
use colored::*;
use rlua::{Result, Value};
use std::io::Write;

pub fn lua_print<'lua>(lua_ctx: rlua::Context<'lua>, value: Value<'lua>) -> Result<()> {
let mut str = String::from("nil");
if let Some(lua_str) = lua_ctx.coerce_string(value)? {
str = lua_str.to_str()?.to_string();
}

match writeln!(std::io::stdout(), "[{}] {}", "lua".cyan(), str) {
Ok(_) => Ok(()),
Err(e) => Err(rlua::Error::external(e)),
}
}

pub fn log(message: &str) {
println!("[{}] {}", "rust".red(), message);
}

Now we can utilize these functions in our src/main.rs

// File: src/main.rs
use logger::{log, lua_print}; // new line
// rest of use statements

// ...

fn exec_lua_code() -> Result<()> {
let lua_code = fs::read_to_string("game/main.lua").expect("Unable to read the Lua script");

let lua = Lua::new();
lua.context(|lua_ctx| {
log("🔧 Loading Lua bindings");
lua_ctx
.globals()
.set("__rust_bindings_print", lua_ctx.create_function(lua_print)?)?;
lua_ctx.load(&lua_code).exec()?;

Ok(())
})
}

// ...

Our colorized output now works! And now we can easily differentiate between our rust and lua runtime.

Working colorized output

Making the mods structure

Let’s think for a second about what our mods will look like.

What comes to mind is the following structure:

/game
|--/mods
|--/base
|----mod.json
|----mod.lua
|--/dlc
|----mod.json
|----mod.lua
|--main.lua

The .json file will have the following schema:


{
"name": "base",
"version": "0.0.1",
"description": "A mod for the base game.",
"author": "Stefan Kupresak"
}

Loading mods in Rust

Similar to how we created the logger module, we are going to create a mods rust module now:

touch src/mods.rs

And add a reference to it in our main.rs

// File: src/main.rs
mod mods;

For this module, we’ll also need to pull in some new cargo dependencies for helping us parse the mod json manifest files:

cargo add serde_json

And for serde we’re going to manually add it to Cargo.toml

[dependecies]
...
serde = { version = "*", features = ["derive"] }

Now, we can implement the mods.rs module:

// File: src/mods.rs
use std::fs;
use rlua::{MetaMethod, UserData, UserDataMethods};
use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize, Clone)]
pub struct Mod {
pub name: String,
pub version: String,
pub description: String,
pub author: String,
}

/**
* UserData is used from rlua so
* we can pass the Vec<Mod> to our
* Lua context
**/
impl UserData for Mod {}

/**
* This helps us later to convert
* the mods struct to a lua table
**/
pub fn items_to_lua_table<'lua>(lua_ctx: &Context<'lua>, items: Vec<Mod>) -> Result<Table<'lua>> {
let table = lua_ctx.create_table()?;
for (i, item) in items.iter().enumerate() {
// lua tables start from index 1 :)
// see: https://www.tutorialspoint.com/why-do-lua-arrays-tables-start-at-1-instead-of-0
table.set(i + 1, item.clone())?;
}
Ok(table)
}

fn list_mods_root() -> Vec<String> {
let mut mods = vec![];

for entry in fs::read_dir("game/mods").expect("Unable to read the mods directory") {
let entry = entry.expect("Unable to read the mods directory");
let path = entry.path();

if path.is_dir() {
let mod_json_path = path.join("mod.json");
if mod_json_path.exists() {
mods.push(mod_json_path.to_str().unwrap().to_string());
}
}
}

mods
}

pub fn load() -> Vec<Mod> {
let mod_paths = list_mods_root();
let mut mods = vec![];

for mod_json_path in mod_paths {
let mod_json = fs::read_to_string(mod_json_path).expect("Unable to read the mod.json file");
let mod_json = serde_json::from_str(&mod_json).expect("Unable to parse the mod.json file");
mods.push(mod_json);
}

mods
}

Exciting! Now we can load the mods from our main.rs file.

// File src/main.rs
fn main() -> Result<()> {
let mods = mods::load();
log(format!("Loaded {} mods", mods.len()).as_str());
exec_lua_code();
}

Now we should have the following output:

[rust] Loaded 2 mods
[rust] 🔧 Loading Lua bindings
[lua] Lua 5.4
[lua] 🌙 Lua is working!

Keep in mind that our game/ directory looks like this:

How our current game directory looks

Passing our mods vector to Lua

So far so good. Finally, we need to pass our vector to lua and start building out the “framework” for loading these mods.

First, let’s pass our mods to our exec_lua_code function:

// File src/main.rs
fn exec_lua_code(mods: Vec<Mod>) -> Result<()> {
/* function body */
}

fn main() -> Result<()> {
let mods = mods::load();
/* ... */
exec_lua_code(mods)
}

Now we can pass along our struct to the Lua context:

// File src/main.rs
fn exec_lua_code(mods: Vec<Mod>) -> Result<()> {
/* ... */
lua.context(|lua_ctx| {
/* ... */
let mods_table = mods::items_to_lua_table(&lua_ctx, mods)?;
lua_ctx.globals().set("mods", mods_table)?;
/* ... */
})
}

We can verify it works by doing the following in our main.lua

print("number of mods -> " .. #mods)

UserData and Lua

One strange thing though, is if we try to access a particular mod, we get this userdata construct.

print("first mod: ", mods[1]) -- userdata 0x5587a65d49e8

This is a special construct in lua, which points to a block of raw memory. What’s interesting is that we can’t access it as a table in the way you expect:

print("first mod name: ", mods[1].name) -- throws an error

The reason this happens is because lua doesn’t know how to index this data structure. In order to make it indexable” we have to implement the __index function in its meta table.

rlua adds a convenient way for us to do so. By implementing the UserData trait we can extend this user data and add this metamethod which will allow us to index this data in Lua:

// File: src/mods.rs
impl UserData for Mod {
fn add_methods<'lua, M: UserDataMethods<'lua, Self>>(methods: &mut M) {
methods.add_meta_method(MetaMethod::Index, |_, modd: &Mod, key: String| {
match key.as_str() {
"name" => Ok(modd.name.clone()),
"version" => Ok(modd.version.clone()),
"description" => Ok(modd.description.clone()),
"author" => Ok(modd.author.clone()),
_ => Err(rlua::Error::external(format!("Unknown key: {}", key))),
}
})
}
}

Now if we try the same line again, it simply works!

print("first mod name: ", mods[1].name)
[lua] first mod name: 
[lua] base

Building out a small Lua game framework

With the rust part completed, we can now extend the Lua script and make a few mod.lua files to play around with our new system.

In this system, I’ve decided to have a single mod.lua entry file for each mod and force it to be a module.

-- File: game/main.lua

-- rest of main.lua file

Game = {}
function Game:new()
o = {}
self.__index = self
return setmetatable(o, self)
end

function Game:load()
-- Starts loading the game
print("⚡ Loading game")

-- Load the game mods
print("🚧 Loading " .. #mods .. " mod(s)")

for _, mod in ipairs(mods) do
print("✅ Loading mod " .. mod.name)
local f, err = loadfile("game/mods/" .. mod.name .. "/mod.lua")

if f then
m = f()

if m and m.init then
m:init(mod)
else
print("❌ Mod " .. mod.name .. " does not have an init function")
end
else
print("❌ Mod " .. mod.name .. " failed to load: " .. err)
end
end
end

game = Game:new()
game:load()

Keep in mind this example works with the following game/mods/base/mod.lua file

-- File: game/mods/base/mod.lua
mod = {}

function mod:init(mod)
print("🕹️ Initializing mod " .. mod.name)
end

return mod

Will produce:

➜ cargo run
Finished dev [unoptimized + debuginfo] target(s) in 0.01s
Running `target/debug/modding-example`
[rust] 🔧 Loading Lua bindings
[rust] 🚀 executing lua script
[lua] ⚡ Loading game
[lua] 🚧 Loading 2 mod(s)
[lua] ✅ Loading mod base
[lua] 🕹️ Initializing mod base
[lua] ✅ Loading mod dlc
[lua] ❌ Mod dlc does not have an init function

Summary

In this article, we explore the powerful combination of Lua and Rust to create a flexible and high-performance modding system for games. We walk through setting up a new Cargo project and integrating Lua, followed by designing a clear and organized mod structure. By leveraging the strengths of both technologies, we demonstrate how Lua and Rust can work together to enable endless creativity and bring new life to games through user-generated content.

🎊🎊 Congratulations if you’ve made it this far! You’ve successfully ventured into the world of Lua and Rust, creating a solid foundation for a flexible and high-performance modding system. As you continue to explore and experiment, you’ll undoubtedly uncover even more exciting possibilities and ideas to enhance your gaming projects. The journey you’ve embarked on is just the beginning — the potential of Lua and Rust working together is vast, and we can’t wait to see what you create next. Keep pushing the boundaries and happy modding!

This article has an open-source GitHub repo.

--

--

Hello. I’m a full-stack developer specializing in Elixir/Phoenix and React, and I love going into lots of details in any problem.