Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 76

Detecting Content ID Conflicts

Recommended resources

Hello! Welcome to the final lesson of our course on building a moddable life simulation game.

In our last session, you built a robust, multi-file mod loader that can scan a directory and execute Lua scripts. This was a huge step, but our system has a critical vulnerability: if two mods try to register content with the same ID—for example, two different traits both named "observant"—the last one loaded silently overwrites the first. This can lead to confusing bugs that are hard for players and mod authors to diagnose.

Today, we'll fix that. Our learning outcome is to implement basic conflict detection for mods that register content with the same ID. We'll build a central registry to track all content IDs and stop a mod from loading if it tries to use an ID that's already taken, providing a clear error message instead.

1. The Concept of a Content Registry

To detect conflicts, our Rust backend needs to remember every content ID that has been registered and which mod registered it. This central tracking system is often called a registry.

The logic will be simple:

  1. When a mod calls game.register_trait(my_trait), our Rust function will extract the trait's ID.
  2. It will look up this ID in our registry.
  3. If the ID is already present, we have a conflict. We will halt the execution of the current mod's script and report an error.
  4. If the ID is not present, we will add the ID and the name of the mod to our registry and proceed with the registration.
AstroModLoader Classic Interface
This is an example of a mod loader UI. Behind the scenes, a robust loader must perform checks like the one we're building today to ensure mods are compatible and to report issues to the user.

2. Choosing the Right Data Structure: HashMap

We need a data structure that is fast at checking for the existence of a key (the content ID). For this task, Rust's HashMap is the perfect tool. We will use a HashMap<String, String>, where the key is the content ID and the value is the name of the mod that registered it. Storing the mod name allows us to create highly informative error messages, like "Conflict: trait 'observant' was already registered by mod 'core_tweaks'."

To understand why HashMap is the ideal choice, it's helpful to review its purpose among Rust's standard collections.

std::collections - Rust Documentation

The official Rust documentation provides a high-level guide on when to use each collection type. This will confirm why HashMap is the correct choice for our registry.

Read the section 'When Should You Use Which Collection?', focusing on the description for HashMap. Also, look at the 'Cost of Collection Operations' table to see its performance characteristics. Its expected O(1) time for lookups (get) and insertions (insert) makes it extremely efficient for our needs.


3. Implementing the Registry in Rust

Let's start by modifying our Rust code. We will define a ModRegistry struct and integrate it into our mod loading process. Because this registry needs to be accessed and modified by API calls from different mods within the same Lua instance, we need to manage shared mutable state. The standard way to do this in a single-threaded context is with Rc<RefCell<...>>.

First, let's define the registry in src/modding.rs.

src/modding.rs

use std::collections::HashMap;
use std::rc::Rc;
use std::cell::RefCell;
// ... other use statements ...




#[derive(Default, Debug)]
pub struct ModRegistry {
    pub traits: HashMap<String, String>,
    // We can add more for conversation packs, items, etc. later
    // pub conversation_packs: HashMap<String, String>,
}

Next, we'll update initialize_and_run_mods to create and use this registry. A key challenge is letting our Lua API functions (like register_trait) know which mod is currently calling them. A clean way to do this is to set a temporary global variable in Lua right before executing each mod's script.

src/modding.rs (Updated initialize_and_run_mods and game_module)

// In modding.rs

// This function now sets up the API with access to the registry
fn game_module(lua: &mlua::Lua, registry: Rc<RefCell<ModRegistry>>) -> mlua::Result<mlua::Table> {
    let game_table = lua.create_table()?;

    // --- Register Trait Function ---
    let registry_clone = registry.clone();
    let register_trait_fn = lua.create_function(move |lua, trait_table: mlua::Table| {
        let mod_name: String = lua.globals().get("CURRENT_MOD_NAME")?;
        let id: String = trait_table.get("id")?;
        let name: String = trait_table.get("name")?;

        let mut registry = registry_clone.borrow_mut();
        
        // Check for conflicts
        if let Some(original_mod) = registry.traits.get(&id) {
            return Err(mlua::Error::RuntimeError(format!(
                "Conflict for trait ID '{}' from mod '{}'. Already registered by '{}'.",
                id, mod_name, original_mod
            )));
        }

        // No conflict, register it
        println!("[Rust] Registering trait '{}' ({}) from mod '{}'", name, id, mod_name);
        registry.traits.insert(id, mod_name);
        
        // Here you would add the trait to your actual game state
        
        Ok(())
    })?;
    game_table.set("register_trait", register_trait_fn)?;

    // You would add register_conversation_pack etc. here following the same pattern

    Ok(game_table)
}

pub fn initialize_and_run_mods() -> mlua::Result<()> {
    let lua = mlua::Lua::new();
    let globals = lua.globals();
    
    // Create the shared registry
    let registry = Rc::new(RefCell::new(ModRegistry::default()));

    // Expose the 'game' API, now with registry access
    globals.set("game", game_module(&lua, registry.clone())?)?;
    println!("[Rust] Lua environment initialized and 'game' API exposed.");

    println!("[Rust] --- Starting Mod Scan ---");
    let mods_dir = "./mods";

    match std::fs::read_dir(mods_dir) {
        Ok(entries) => {
            for entry in entries.filter_map(Result::ok) {
                let path = entry.path();
                if path.is_dir() {
                    let mod_name = path.file_name().unwrap().to_str().unwrap();
                    let main_script_path = path.join("main.lua");

                    if main_script_path.exists() {
                        println!("[Rust] Found mod '{}', executing main.lua...", mod_name);

                        // Set the mod's context before running its script
                        globals.set("CURRENT_MOD_NAME", mod_name)?;

                        if let Some(mod_path_str) = path.to_str() {
                            let mod_lua_path = format!("{}/?.lua", mod_path_str.replace('\\', "/"));
                            let set_path_script = format!("package.path = '{}..' .. package.path", mod_lua_path);
                            lua.load(&set_path_script).exec()?;
                        }
                        
                        let script_content = std::fs::read_to_string(&main_script_path)
                            .expect("Could not read main.lua");
                        
                        // Execute the script
                        if let Err(e) = lua.load(&script_content)
                           .set_name(main_script_path.to_str().unwrap())
                           .exec() {
                             // Print a clear error message and stop this mod, but continue to the next
                             eprintln!("[Rust] ERROR loading mod '{}': {}", mod_name, e);
                        }
                    }
                }
            }
        },
        Err(e) => eprintln!("[Rust] WARN: Could not read mods directory '{}': {}", mods_dir, e),
    }
    println!("[Rust] --- Mod Scan Complete ---");

    // Print the final state of the registry for debugging
    println!("[Rust] Final registry: {:?}", registry.borrow());

    Ok(())
}

This is a significant change, so let's break it down:

  1. We created a ModRegistry struct to hold our HashMap.
  2. We instantiate it inside an Rc<RefCell<...>> to allow shared, mutable access.
  3. The game_module function now accepts the registry and creates the register_trait closure, which captures a clone of the Rc.
  4. Inside the mod-loading loop, we set a global Lua variable CURRENT_MOD_NAME before executing each mod's script.
  5. The register_trait function now reads this global variable to know who is calling it.
  6. It checks for an existing ID in the registry. If a conflict is found, it returns a mlua::Error, which will stop the execution of the offending Lua script and be caught by our if let Err(e) block. If not, it registers the trait.

4. Improving the Logic with the entry API

Our conflict-checking logic works, but it involves a separate get and insert call. Rust's HashMap provides a more efficient and idiomatic way to handle this: the entry API. It lets you inspect a key and atomically perform an action based on whether it's vacant or occupied.

Hash Maps - The Rust Programming Language - MIT

The Rust Programming Language book has a fantastic section on HashMap that demonstrates exactly how to use the entry API for conditional insertion. This is the pattern we will adopt.

Focus on the subsection named 'Only Insert If the Key Has No Value'. The example with scores.entry(String::from("Yellow")).or_insert(50); is precisely the pattern we need. The entry function avoids doing two separate lookups for the key.

Let's refactor our register_trait_fn to use this superior pattern.

src/modding.rs (Updated register_trait_fn closure)

// Inside game_module, replace the register_trait_fn closure with this:
let register_trait_fn = lua.create_function(move |lua, trait_table: mlua::Table| {
    let mod_name: String = lua.globals().get("CURRENT_MOD_NAME")?;
    let id: String = trait_table.get("id")?;
    let name: String = trait_table.get("name")?;

    let mut registry = registry_clone.borrow_mut();
    
    // Use the entry API for an atomic check-and-insert
    use std::collections::hash_map::Entry;
    match registry.traits.entry(id.clone()) {
        Entry::Occupied(entry) => {
            // Conflict! The entry is already occupied.
            let original_mod = entry.get();
            Err(mlua::Error::RuntimeError(format!(
                "Conflict for trait ID '{}' from mod '{}'. Already registered by '{}'.",
                id, mod_name, original_mod
            )))
        },
        Entry::Vacant(entry) => {
            // No conflict, insert the new entry.
            println!("[Rust] Registering trait '{}' ({}) from mod '{}'", name, id, mod_name);
            entry.insert(mod_name);
            // Here you would add the trait to your actual game state
            Ok(())
        }
    }
})?;

This version is cleaner and more efficient. The match statement clearly separates the logic for the "conflict" and "no conflict" cases.


5. Practical Exercise: Creating a Conflict

Now it's time to see our system in action. We'll create a new mod that intentionally tries to register a trait with an ID that already exists.

  1. Ensure you have the core_tweaks mod from the previous lesson, which registers the "observant" trait.

  2. Create a new mod folder: mods/conflict_test.

  3. Inside this new folder, create a main.lua file with the following content:

    mods/conflict_test/main.lua

    print('[Lua] conflict_test mod is loading!')
    
    local conflicting_trait = {
        id = "observant", -- This ID will conflict with core_tweaks
        name = "Super Observant",
        description = "Notices literally everything."
    }
    
    print('[Lua] Attempting to register a conflicting trait...')
    game.register_trait(conflicting_trait)
    
    -- This line will NOT be printed if the conflict is detected correctly
    print('[Lua] Conflicting trait was registered successfully. (This is a bug!)')
    
  4. Run your game project. Look closely at the console output. Because core_tweaks loads first alphabetically, it will successfully register "observant". When conflict_test tries to do the same, our new logic should catch it.

You should see an error message similar to this:

[Rust] Found mod 'core_tweaks', executing main.lua...
[Lua] core_tweaks mod is loading!
[Rust] Registering trait 'Observant' (observant) from mod 'core_tweaks'
[Lua] Registered new trait: observant
[Rust] Found mod 'conflict_test', executing main.lua...
[Lua] conflict_test mod is loading!
[Lua] Attempting to register a conflicting trait...
[Rust] ERROR loading mod 'conflict_test': API call failed: Conflict for trait ID 'observant' from mod 'conflict_test'. Already registered by 'core_tweaks'.
[Rust] ... (continues to the next mod if any) ...
[Rust] --- Mod Scan Complete ---
[Rust] Final registry: ModRegistry { traits: {"observant": "core_tweaks"} }

Notice that the error message is specific, and the final line in conflict_test/main.lua never runs. Our conflict detection is working perfectly!


Conclusion

Congratulations on completing the final lesson and the course! You have successfully fortified your modding framework by implementing a critical safety feature. Instead of allowing mods to silently overwrite each other's data, your game now detects ID conflicts, reports them with a clear error, and prevents the offending mod from causing difficult-to-trace bugs.

Key Takeaways from this Lesson:

  • A content registry is essential for managing mod-added content and preventing conflicts.
  • Rust's HashMap is the ideal data structure for building a fast registry, and its entry API provides an efficient, idiomatic way to handle conditional insertions.
  • Communicating failures from Rust to Lua via mlua::Result::Err is how you enforce rules and stop problematic scripts.
  • Providing descriptive error messages is crucial for making a modding system usable for creators.

You've built a solid foundation for your life simulation game, from the Rust backend and Lua scripting API to the initial game systems and a safe, scalable mod loader. The path forward is to continue building on this foundation, implementing the rich gameplay features you envisioned. Well done