Skip to main content

Welcome to a Grasp lesson

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

Lesson 5

FFI for Rust Libraries

Recommended resources

Hello! Welcome to the fifth and final lesson of our first module.

In our previous lesson, you mastered a crucial skill: serializing and deserializing complex data structures using serde. We can now convert our game's state (like characters and inventories) into JSON for saving and loading. This ability to manage data is the foundation of our game's core logic.

Today, we will build the bridge that connects this Rust-powered core to the Unity frontend. Our learning outcome is to build a simple Rust library that exposes functions to other languages via a C-compatible Foreign Function Interface (FFI).

This is the final step in preparing our Rust backend. We'll transform our Rust application into a dynamic library (.dll or .so) that Unity's C# scripts can communicate with. This lets us keep high-performance logic in Rust while leveraging Unity's powerful rendering and presentation tools.

Let's begin by understanding what an FFI is.

1. What is a Foreign Function Interface (FFI)?

An FFI is a mechanism that allows code written in one programming language to call functions written in another. It acts as a translator, ensuring that both languages agree on how to pass data and call functions.

Getting started with FFI: Rust & Unity

The article 'Getting started with FFI: Rust & Unity' from Test Double provides an excellent, concise definition of FFI.

Please read the first two sections, 'Getting started with FFI: Rust & Unity' and 'What is FFI?'. This will give you a clear understanding of the concept.

In our case, the FFI will create a "contract" between Rust and C#. Rust will expose a set of functions using a C-style application binary interface (ABI), which C# natively understands how to call. This allows our Unity game to send commands to the Rust engine and receive results.

Safer FFI for Rust Interoperability
This image visualizes our goal: creating a Rust library that can be called from various languages, including C# for our Unity project.

2. Configuring the Rust Project

By default, cargo builds an executable. To create a library that other programs can use, we need to tell Cargo to build a dynamic library instead. We do this by modifying our Cargo.toml file.

Open your project's Cargo.toml and add the following [lib] section. It's common to place it just above the [dependencies] section.

[package]
name = "game_engine_core"
version = "0.1.0"
edition = "2021"

[lib]
name = "game_engine_core"
crate-type = ["cdylib"]

[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

Let's break this down:

  • [lib]: This section tells Cargo we are configuring a library build.
  • name: This sets the output filename for our library.
  • crate-type = ["cdylib"]: This is the crucial part. It instructs the compiler to create a C-style Dynamic Library. This is the ideal format for FFI, as it's designed to be loaded by other languages and creates a more stable interface than the dylib alternative.

3. Exposing a Rust Function

To make a Rust function callable from C#, we need to do two things:

  1. Use the extern "C" keyword to tell Rust to compile the function with the C ABI.
  2. Use the #[no_mangle] attribute to prevent the Rust compiler from changing the function's name during compilation.

Let's see the simplest possible example.

How to make NATIVE PLUGINS in Unity using RUST

This short clip from Ryan Hedgecock's video 'How to make NATIVE PLUGINS in Unity using RUST' provides a perfect, minimal example of exposing a function.

Watch the segment from 1:42 to 2:14. It clearly demonstrates the use of extern "C" and #[no_mangle] on a simple add_one function.

Now, let's apply this to our project. Since we've added a [lib] section, Cargo will now look for a src/lib.rs file as the entry point for our library, instead of src/main.rs.

  1. Rename your src/main.rs to src/lib.rs.
  2. Replace the contents of src/lib.rs with the following starter code:
// A simple function to test the FFI bridge.



#[no_mangle]
pub extern "C" fn add_one(x: i32) -> i32 {
    x + 1
}

// To prove it compiles, we need a main function for `cargo run`
// But library builds don't have a main. So we can add a test.
// You can run tests with `cargo test`.



#[cfg(test)]
mod tests {
    use super::*;




    #[test]
    fn it_works() {
        assert_eq!(2, add_one(1));
    }
}

Now, when you run cargo build, Cargo will compile src/lib.rs into a dynamic library.

  • On Windows, you'll find game_engine_core.dll in target/debug/.
  • On macOS, it will be libgame_engine_core.dylib.
  • On Linux, it will be libgame_engine_core.so.

This file is our game engine! In the next module, we'll drag this file into our Unity project.


4. Managing State Across the FFI Boundary

Our simple add_one function is "stateless"—it doesn't need to remember anything. But our game engine needs to manage the entire game state: characters, needs, relationships, etc. How do we do that when C# is just calling individual functions?

The answer is a common FFI pattern:

  1. Rust allocates the game state on the heap and passes an opaque pointer (a "handle" or "baton") back to C#.
  2. C# holds onto this pointer. To perform any action, it passes the pointer back to a Rust function.
  3. Rust uses the pointer to access and modify the state.
  4. When the game is over, C# passes the pointer to a final "destroy" function in Rust, which properly deallocates the memory.

This pattern ensures that memory allocated by Rust is freed by Rust, preventing memory leaks.

Let's implement this pattern. We'll use the CharacterState struct from our last lesson, but simplified for clarity.

Update your src/lib.rs:

use serde::{Serialize, Deserialize};

// Simplified struct from last lesson



#[derive(Serialize, Deserialize)]
pub struct GameState {
    name: String,
    level: u32,
    health: u32,
}

/// Creates a new GameState and returns a raw pointer to it.
/// The caller is responsible for calling destroy_state later.



#[no_mangle]
pub extern "C" fn create_game_state() -> *mut GameState {
    let state = GameState {
        name: String::from("Aris"),
        level: 1,
        health: 100,
    };
    // Box the state to allocate it on the heap, then return a raw pointer.
    Box::into_raw(Box::new(state))
}

/// Frees the memory for a GameState.
/// Must be called for every pointer returned by create_game_state.



#[no_mangle]
pub unsafe extern "C" fn destroy_game_state(state_ptr: *mut GameState) {
    if state_ptr.is_null() {
        return;
    }
    // Convert the raw pointer back into a Box to let Rust deallocate it.
    let _ = Box::from_raw(state_ptr);
}

/// Takes a pointer to the GameState and increases the level.



#[no_mangle]
pub unsafe extern "C" fn level_up(state_ptr: *mut GameState) {
    if state_ptr.is_null() {
        return;
    }
    // Get a mutable reference from the pointer.
    let state = &mut *state_ptr;
    state.level += 1;
}

/// Gets the current level from the GameState.



#[no_mangle]
pub unsafe extern "C" fn get_level(state_ptr: *const GameState) -> u32 {
    if state_ptr.is_null() {
        // Return a default/error value
        return 0;
    }
    // Get an immutable reference from the pointer.
    let state = &*state_ptr;
    state.level
}




#[cfg(test)]
mod tests {
    use super::*;




    #[test]
    fn state_management_works() {
        // 1. Create state
        let state_ptr = create_game_state();
        assert!(!state_ptr.is_null());

        // 2. Use and modify state through the pointer
        unsafe {
            assert_eq!(get_level(state_ptr), 1);
            level_up(state_ptr);
            assert_eq!(get_level(state_ptr), 2);
        }

        // 3. Destroy state
        unsafe {
            destroy_game_state(state_ptr);
        }
    }
}

Let's analyze the key new concepts here:

  • Box::new(state): This allocates our GameState struct on the heap and returns a smart pointer (Box).
  • Box::into_raw(...): This consumes the Box and returns a raw pointer (*mut GameState). Crucially, it forfeits Rust's memory management for this pointer. Rust will not automatically deallocate it. This is exactly what we want, because we're handing control over to the C# side.
  • unsafe: Dereferencing raw pointers is an unsafe operation in Rust because the compiler cannot guarantee the pointer is valid. We are taking responsibility for ensuring we only pass valid pointers.
  • Box::from_raw(state_ptr): This is the reverse of into_raw. It takes the raw pointer, reconstructs a Box from it, and now Rust's memory management takes over again. When the Box goes out of scope at the end of the destroy_game_state function, the memory is freed.

This create/use/destroy pattern is fundamental to our Rust-Unity architecture.


Conclusion

Congratulations on completing the first module! You've built a solid foundation in Rust and are now ready to connect it to Unity.

Key Takeaways:

  • A Foreign Function Interface (FFI) allows code from different languages to interoperate.
  • We configure our Rust project as a library in Cargo.toml using crate-type = ["cdylib"].
  • We expose functions to C# using the #[no_mangle] attribute and the pub extern "C" keywords.
  • State is managed across the FFI boundary by passing opaque pointers. Rust creates the state and gives a pointer to C#. C# passes this pointer back to Rust for any operations.
  • The create function uses Box::into_raw to transfer pointer ownership, and the destroy function uses Box::from_raw to reclaim ownership and free the memory. This manual memory management is critical to prevent leaks.

In the next module, Rust-Unity Project Architecture, we will finally see the payoff. Our very first lesson will involve creating a Unity project, importing the .dll you just built, and using C#'s DllImport attribute to call these create_game_state, level_up, and get_level functions. This is where the two halves of our game will meet for the first time.