00%
Loading...

Game Scripting Engine with Rust and WASM

keyboard_arrow_down

While building my own game engine from scratch, I realized an essential feature was missing—scripting. Most modern game engines provide scripting capabilities, allowing developers to write gameplay logic without needing to recompile the entire engine. I knew this was a must-have for my engine as well.

After doing some research, I discovered that Lua is a popular choice for scripting systems. Since I’m working with Rust, integrating Lua seemed straightforward, especially with crates like rlua available to streamline the process.

However, I had a problem: I don’t particularly enjoy Lua. The syntax doesn’t appeal to me, and while Lua supports object-oriented programming through tables, it doesn’t feel intuitive for someone who prefers a more classic OOP style—something akin to C++, C#, JavaScript, or even Ruby. Of course, this comes down to personal preference, but it left me wanting an alternative.

That’s when I stumbled upon a game-changing idea: leveraging WebAssembly (WASM). Many modern programming languages can compile to WASM, meaning I could potentially support multiple languages in my engine. By exposing the necessary APIs to WASM modules and running them in a sandboxed environment, I could offer developers the flexibility to use their preferred language while maintaining security and performance.

This realization not only solved my scripting dilemma but also opened the door to a more modular and versatile engine design. With WASM, scripting in my engine could become a powerful and flexible feature, catering to developers with a variety of coding preferences.

Time for WASM!

Wasmtime is a WebAssembly runtime. It can run WASM modules as regular scripts or programs, it is also secure and configurable, and can be used as a library to embed WASM execution support within application which is exactly what we need.

To use the library, we first need to add it to the cargo project:

cargo add wasmtime

The documentation for wasmtime provides lots of examples on how to set it up in your project, it all comes down to the following components:

  • Engine: The global context of compilation of wasm modules, also storing configuration.
  • Linker: A structure that we are using to link multiple wasm modules and instances together.
  • Store: Can be interpreted as a host-defined state, all the instances of WASM and items will be attached here. Since my game engine runs with an ECS, I am providing it to the scripting engine so that it can be used to create entities in the scripts
pub(crate) struct ScriptStore {
    wasi: WasiCtx,
    ecs: Arc<Mutex<ECS>>, // Entity component system of the engine
}

pub struct ScriptingEngine {
    engine: Engine,
    store: Store<ScriptStore>,
    linker: Linker<ScriptStore>,
}

impl ScriptingEngine {
    pub fn new(ecs: Arc<Mutex<ECS>>) -> Self {
        let engine = Engine::new(
            Config::new()
                .debug_info(true)
                .cranelift_opt_level(OptLevel::None),
        )
        .unwrap();
        let mut linker = Linker::new(&engine);

        // WASI support so we can use basic functionality like writing to console
        wasi_common::sync::add_to_linker(&mut linker, |state: &mut ScriptStore| &mut state.wasi)
            .unwrap();

        let wasi = WasiCtxBuilder::new()
            .inherit_stdio()
            .inherit_args()
            .unwrap()
            .build();

        let store = Store::new(&engine, ScriptStore { wasi, ecs });

        Self {
            engine,
            store,
            linker,
        }
    }
}

Choosing the scripting language

Remember when I mentioned that you can use any language that compiles to WebAssembly (WASM) as a scripting language? Now, it’s time to pick one to start with. If you’d like, you can even implement support for multiple languages later on. This would involve providing the necessary bindings for each language, which we’ll explore in the later stages of this project.

For my engine, I’ve decided to go with AssemblyScript (AS) as the first scripting language. AssemblyScript has a syntax very similar to TypeScript, making it familiar and approachable for many developers. However, there are a few key considerations we need to address when working with AS and compiling it to WASM.

One of the most important aspects is ensuring that the runtime is exported during compilation. This is crucial because we’ll be working with the internal memory of the WASM module, and the runtime provides essential functionality, such as garbage collection (GC) and memory allocation. Specifically, we need access to the __new function for creating new objects in the module’s memory. The equivalent function may vary depending on the language you choose—for example, malloc in C.

Declarations

Since AS is a type strict language, we need to write declaration files just like in TypeScript, to define which functions or classes are we going to provide with wasmtime using the Linker. Below is an example of a declaration file:

@final
export class Vector2 {
  constructor(public x: f32, public y: f32) {
    const ptr = __new(8, idof<Vector2>());
    store<f32>(ptr, x);
    store<f32>(ptr + 4, y);
    return changetype<Vector2>(ptr);
  }

  @operator("+")
  static add(left: Vector2, right: Vector2): Vector2 {
    return new Vector2(left.x + right.x, left.y + right.y);
  }
  
  public toString(): string {
    return `[${this.x.toString()}, ${this.y.toString()}]`
  };
}

/**
 * 2D Transformation component
 */
export declare class Transform {
  constructor(public position: Vector2, public scale: Vector2);
}

export declare class Entity {
  constructor();
  addComponent<T>(component: T): this;
}

You would need to ship the declarations as a module so that users of your engine could install and import it.

Let’s write the entry point of our script so that we could compile it to WASM

import { Vector2, Entity, Transform } from "<your declaration file>";

// Note this is just an example, you can structure your scripts how you like
export default function main(): void {
  const position = new Vector2(1, 1);
  const player = new Entity()
    .addComponent(new Transform(position, new Vector2(10, 10)));
}
Make sure to add this into your asconfig.json file
"options": {
    "exportRuntime": true
}

To compile AS to WASM

npm run asbuild

Providing functions from Rust

To provide the functions we specified in our declaration file, we are going to use the Linker of wasmtime.

Let’s start by defining a trait so we can implement bindings for structs in Rust

pub trait ScriptableObject {
    fn bindings(linker: &mut Linker<ScriptStore>) -> wasmtime::Result<&mut Linker<ScriptStore>>;
}
Then we implement the trait for our objects, for example specs::Entity
impl ScriptableObject for specs::Entity {
    fn bindings(linker: &mut Linker<ScriptStore>) -> wasmtime::Result<&mut Linker<ScriptStore>> {
        linker
            .func_wrap(
                "pixa",
                "Entity#constructor",
                |mut caller: Caller<'_, ScriptStore>, _ptr: i32| {
                    let mut store_context = caller.as_context_mut();

                    let entity = {
                        let data = store_context.data_mut();
                        let mut ecs = data.ecs.lock().unwrap();
                        ecs.world.create_entity().build()
                    };

                    let mem_ptr = Memory::allocate_heap_object(&mut caller, entity.id());

                    mem_ptr
                },
            )?
            .func_wrap(
                "pixa",
                "Entity#addComponent<scripts/pixa/Transform>",
                |mut caller: Caller<'_, ScriptStore>, ptr: i32, value: i32| {
                    let comp_ptr = Memory::allocate_heap(&mut caller, 4, 0);
                    println!("Adding transform ptr {ptr} value {value} newPtr {comp_ptr}");
                    Memory::write_bytes_at(&mut caller, &value.to_le_bytes(), comp_ptr);
                    // Get entity id
                    let entity_id = Memory::read::<u32>(&mut caller, ptr);

                    // Get position
                    let position_ptr = Memory::read::<i32>(&mut caller, value);
                    let position = Memory::read::<Vec2>(&mut caller, position_ptr);

                    // Get scale
                    let scale_ptr = Memory::read::<i32>(&mut caller, value + 4);
                    let scale = Memory::read::<Vec2>(&mut caller, scale_ptr);

                    // Create Transform component
                    let transform = Transform { position, scale };

                    let mut ecs = caller.data().ecs.lock().unwrap();
                    ecs.add_component(entity_id, transform, ptr, comp_ptr);

                    Ok(ptr)
                },
            )
    }
}

Memory management

As you can see we have used a Memory struct which we have not mentioned yet. When working with WASM we are going to manipulate memory directly, using the functions provided by the runtime, in the case of AssemblyScript, we are going to use functions such as __new to store garbage-collected objects, we also take into account the memory layout of the language we chose
pub struct Memory;

impl Memory {
    /// Calls `__new` to allocate a garbage-collected object, returns a pointer
    pub fn allocate_heap(caller: &mut Caller<'_, ScriptStore>, size: usize, class_id: u32) -> i32 {
        let new = match caller.get_export("__new") {
            Some(Extern::Func(func)) => func
                .typed::<(i32, i32), i32>(&caller)
                .expect("Invalid __new signature"),
            _ => panic!("Function `__new` not found"),
        };

        new.call(caller, (size as i32, class_id as i32)).unwrap()
    }

    /// Writes `bytes` directly to memory starting at `offset`
    pub fn write_bytes_at(caller: &mut Caller<'_, ScriptStore>, bytes: &[u8], offset: i32) {
        let memory = caller.get_export("memory").unwrap().into_memory().unwrap();
        memory
            .write(caller, offset as usize, bytes)
            .expect("Failed to write bytes to memory");
    }

    /// Similar to `allocate_heap`, but here we can pass a struct directly to reserve the needed space for it
    pub fn allocate_heap_object<T: NoUninit + AnyBitPattern + std::fmt::Debug>(
        caller: &mut Caller<'_, ScriptStore>,
        obj: T,
    ) -> i32 {
        let bytes = bytemuck::bytes_of(&obj);
        let ptr = Self::allocate_heap(caller, bytes.len(), 0);
        Self::write_bytes_at(caller, bytes, ptr);
        ptr
    }

    /// Reads an object starting at `ptr`
    pub fn read<T: AnyBitPattern>(caller: &mut Caller<'_, ScriptStore>, ptr: i32) -> T {
        let obj_size = std::mem::size_of::<T>();
        let bytes = Self::read_bytes(caller, ptr, obj_size);

        bytemuck::from_bytes::<T>(&bytes).to_owned()
    }

    /// Reads a specific number of bytes starting at `ptr`
    pub fn read_bytes(caller: &mut Caller<'_, ScriptStore>, ptr: i32, bytes: usize) -> Vec<u8> {
        let memory = caller.get_export("memory").unwrap().into_memory().unwrap();
        let mut bytes = vec![0; bytes];
        memory.read(caller, ptr as _, &mut bytes).unwrap();
        bytes
    }

    /// Allocates memory and writes a string to it, returns the pointer to the string
    pub fn write_string<S: AsRef<str>>(
        caller: &mut Caller<'_, ScriptStore>,
        str: S,
    ) -> wasmtime::Result<i32> {
        let str = str.as_ref();
        let utf16_message: Vec<u16> = str.encode_utf16().collect();

        // Multiply by 2 because each letter occupies two spaces
        let length = utf16_message.len() * 2;

        let pointer = Memory::allocate_heap(caller, length, 2);

        // Write the string length (in UTF-16 code units) at -4 bytes
        // This is because of the memory layout of a string in AssemblyScript
        // https://www.assemblyscript.org/runtime.html#memory-layout
        Self::write_bytes_at(caller, &length.to_le_bytes(), pointer - 4);

        // Write the UTF-16 string data into memory
        for (i, &word) in utf16_message.iter().enumerate() {
            Self::write_bytes_at(caller, &word.to_le_bytes(), pointer + (i as i32 * 2));
        }

        Ok(pointer)
    }

    /// Reads a string from memory located at `ptr`
    pub fn read_string(caller: &mut Caller<'_, ScriptStore>, ptr: i32) -> wasmtime::Result<String> {
        let length = Self::read::<i32>(caller, ptr - 4);
        let str_bytes = Self::read_bytes(caller, ptr, length as usize)
            .into_iter()
            .filter(|b| *b != 0)
            .collect();

        Ok(String::from_utf8(str_bytes)?)
    }
}

Conclusion

Some implementations are still missing, such as the Transform constructor. However, it’s fairly straightforward to complete using the example provided above. Just be sure to use the appropriate memory allocation function for the programming language you’re working with.

Using a scripting language that compiles to WebAssembly (WASM) and runs in a sandboxed environment via Wasmtime offers several advantages for game development. It provides a secure and efficient way to execute untrusted or modifiable code, enabling modding support, dynamic content updates, and extensibility without compromising the core engine’s integrity. The portability of WASM ensures that scripts can run consistently across platforms, while Wasmtime’s sandboxing guarantees isolation, protecting the game from potential exploits or crashes caused by user-created scripts. Additionally, the performance of WASM allows for near-native execution speeds, making it a robust choice for high-performance games where dynamic scripting capabilities are essential.

Leave a Reply

Your email address will not be published. Required fields are marked *